diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..1c0307d05 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,15 @@ +changelog: + categories: + - title: 🏕 Features + labels: + - '*' + exclude: + labels: + - dependencies + - breaking_change + - title: 🛠 Breaking Changes + labels: + - breaking_change + - title: 👒 Dependencies + labels: + - dependencies diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index ab5fa31f6..000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,71 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '34 20 * * 2' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - #- name: Autobuild - # uses: github/codeql-action/autobuild@v1 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index a731188cf..5cd6ba40b 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -1,5 +1,13 @@ name: Python CI -on: [push, pull_request] +on: + push: + branches: + - master + - develop + pull_request: + release: + types: [ released ] + jobs: linting: @@ -14,8 +22,14 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: '.pre-commit-config.yaml' + - uses: actions/cache@v3 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - name: Install dependencies - run: pip install pre-commit + run: pip install -U pre-commit - name: Run pre-commit run: pre-commit run --all-files @@ -23,7 +37,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, "3.10"] + python-version: [3.8, 3.9, "3.10"] services: postgres: image: postgres:13 @@ -53,12 +67,13 @@ jobs: env: PIP_USE_MIRRORS: true - name: Run tests and coverage - run: coverage run --source=$SOURCE_FOLDER -m py.test -W ignore::DeprecationWarning -rxXs --reruns 3 + run: coverage run --source=$SOURCE_FOLDER -m pytest -W ignore::DeprecationWarning -rxXs --reruns 3 env: SOURCE_FOLDER: gnosis DJANGO_SETTINGS_MODULE: config.settings.test ETHEREUM_MAINNET_NODE: ${{ secrets.ETHEREUM_MAINNET_NODE }} - - name: Test setup.py + ETHEREUM_POLYGON_NODE: ${{ secrets.ETHEREUM_POLYGON_NODE }} + - name: Test packaging run: pip install -e . - name: Send results to coveralls run: coveralls --service=github @@ -70,7 +85,7 @@ jobs: needs: - linting - test-app - if: startsWith(github.ref, 'refs/tags/') + if: (github.event_name == 'release' && github.event.action == 'released') steps: - uses: actions/checkout@v3 - name: Set up Python diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e81bb4bdf..324e9b752 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,19 +2,19 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/PyCQA/isort - rev: 5.10.1 + rev: 5.12.0 hooks: - id: isort - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 22.12.0 hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 6.0.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 hooks: - id: check-docstring-first - id: check-merge-conflict diff --git a/LICENSE b/LICENSE index 236b5cd17..86aa6415b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 Gnosis Ltd +Copyright (c) 2018 Safe Ecosystem Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docker-compose.yml b/docker-compose.yml index 6267ebcee..09fb3b88a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.5' services: db: - image: postgres:10-alpine + image: postgres:15-alpine ports: - "5432:5432" environment: diff --git a/docs/source/gnosis.eth.eip712.rst b/docs/source/gnosis.eth.eip712.rst new file mode 100644 index 000000000..18fd3c20f --- /dev/null +++ b/docs/source/gnosis.eth.eip712.rst @@ -0,0 +1,10 @@ +gnosis.eth.eip712 package +============================ + +Module contents +--------------- + +.. automodule:: gnosis.eth.eip712 + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 4c30876bf..ba698df49 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -76,6 +76,47 @@ gnosis.eth.constants - ``SENTINEL_ADDRESS (0x000...1)``: Used for Gnosis Safe's linked lists (modules, owners...). - Maximum an minimum values for `R`, `S` and `V` in ethereum signatures. +gnosis.eth.eip712 +~~~~~~~~~~~~~~~~~~~~ +.. code-block:: python + + from gnosis.eth.eip712 import eip712_encode_hash + + types = {'EIP712Domain': [{'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}, + {'name': 'chainId', 'type': 'uint256'}, + {'name': 'verifyingContract', 'type': 'address'}], + 'Mailbox': [{'name': 'owner', 'type': 'address'}, + {'name': 'messages', 'type': 'Message[]'}], + 'Message': [{'name': 'sender', 'type': 'address'}, + {'name': 'subject', 'type': 'string'}, + {'name': 'isSpam', 'type': 'bool'}, + {'name': 'body', 'type': 'string'}]} + + msgs = [{'sender': ADDRESS, + 'subject': 'Hello World', + 'body': 'The sparrow flies at midnight.', + 'isSpam': False}, + {'sender': ADDRESS, + 'subject': 'You may have already Won! :dumb-emoji:', + 'body': 'Click here for sweepstakes!', + 'isSpam': True}] + + mailbox = {'owner': ADDRESS, + 'messages': msgs} + + payload = {'types': types, + 'primaryType': 'Mailbox', + 'domain': {'name': 'MyDApp', + 'version': '3.0', + 'chainId': 41, + 'verifyingContract': ADDRESS}, + 'message': mailbox} + + eip712_hash = eip712_encode_hash(payload) + + + gnosis.eth.oracles ~~~~~~~~~~~~~~~~~~ Price oracles for Uniswap, UniswapV2, Kyber, SushiSwap, Aave, Balancer, Curve, Mooniswap, Yearn... diff --git a/gnosis/eth/clients/blockscout_client.py b/gnosis/eth/clients/blockscout_client.py index f7da25550..31fb6ac51 100644 --- a/gnosis/eth/clients/blockscout_client.py +++ b/gnosis/eth/clients/blockscout_client.py @@ -25,12 +25,14 @@ class BlockscoutClient: EthereumNetwork.ENERGY_WEB_CHAIN: "https://explorer.energyweb.org/", EthereumNetwork.VOLTA: "https://volta-explorer.energyweb.org/", EthereumNetwork.OLYMPUS: "https://explorer.polis.tech", + EthereumNetwork.BOBA_NETWORK_BOBABEAM: "https://blockexplorer.bobabeam.boba.network/", EthereumNetwork.BOBA_RINKEBY: "https://blockexplorer.rinkeby.boba.network/", EthereumNetwork.BOBA: "https://blockexplorer.boba.network/", EthereumNetwork.GATHER_DEVNET: "https://devnet-explorer.gather.network/", EthereumNetwork.GATHER_TESTNET: "https://testnet-explorer.gather.network/", EthereumNetwork.GATHER_MAINNET: "https://explorer.gather.network/", EthereumNetwork.METIS_TESTNET: "https://stardust-explorer.metis.io/", + EthereumNetwork.METIS_GOERLI_TESTNET: "https://goerli.explorer.metisdevops.link/", EthereumNetwork.METIS: "https://andromeda-explorer.metis.io/", EthereumNetwork.FUSE_MAINNET: "https://explorer.fuse.io/", EthereumNetwork.VELAS_MAINNET: "https://evmexplorer.velas.com/", @@ -48,6 +50,14 @@ class BlockscoutClient: EthereumNetwork.KARURA_NETWORK_TESTNET: "https://blockscout.karura.network/", EthereumNetwork.ACALA_NETWORK_TESTNET: "https://blockscout.mandala.acala.network/", EthereumNetwork.ASTAR: "https://blockscout.com/astar/", + EthereumNetwork.EVMOS_MAINNET: "https://evm.evmos.org", + EthereumNetwork.EVMOS_TESTNET: "https://evm.evmos.dev", + EthereumNetwork.RABBIT: "https://rabbit.analogscan.com", + EthereumNetwork.KCC_MAINNET: "https://scan.kcc.io/", + EthereumNetwork.KCC_TESTNET: "https://scan-testnet.kcc.network/", + EthereumNetwork.ARBITRUM: "https://explorer.arbitrum.io", + EthereumNetwork.ARBITRUM_NOVA: "https://nova-explorer.arbitrum.io", + EthereumNetwork.ARBITRUM_GOERLI: "https://goerli-rollup-explorer.arbitrum.io", } def __init__(self, network: EthereumNetwork): diff --git a/gnosis/eth/clients/etherscan_client.py b/gnosis/eth/clients/etherscan_client.py index 4766bbe63..6f0065151 100644 --- a/gnosis/eth/clients/etherscan_client.py +++ b/gnosis/eth/clients/etherscan_client.py @@ -32,12 +32,15 @@ class EtherscanClient: EthereumNetwork.MATIC: "https://polygonscan.com", EthereumNetwork.OPTIMISTIC: "https://optimistic.etherscan.io", EthereumNetwork.ARBITRUM: "https://arbiscan.io", + EthereumNetwork.ARBITRUM_NOVA: "https://nova.arbiscan.io", + EthereumNetwork.ARBITRUM_GOERLI: "https://goerli.arbiscan.io", EthereumNetwork.AVALANCHE: "https://snowtrace.io", EthereumNetwork.MOON_MOONBEAM: "https://moonscan.io", EthereumNetwork.MOON_MOONRIVER: "https://moonriver.moonscan.io", EthereumNetwork.MOON_MOONBASE: "https://moonbase.moonscan.io", EthereumNetwork.CRONOS_MAINNET: "https://cronoscan.com", EthereumNetwork.CRONOS_TESTNET: "https://testnet.cronoscan.com", + EthereumNetwork.CELO: "https://celoscan.io", } NETWORK_WITH_API_URL = { @@ -45,17 +48,20 @@ class EtherscanClient: EthereumNetwork.RINKEBY: "https://api-rinkeby.etherscan.io", EthereumNetwork.ROPSTEN: "https://api-ropsten.etherscan.io", EthereumNetwork.GOERLI: "https://api-goerli.etherscan.io/", - EthereumNetwork.KOVAN: "https://api-kovan.etherscan.io/", + EthereumNetwork.KOVAN: "https://api-kovan.etherscan.io", EthereumNetwork.BINANCE: "https://api.bscscan.com", EthereumNetwork.MATIC: "https://api.polygonscan.com", EthereumNetwork.OPTIMISTIC: "https://api-optimistic.etherscan.io", EthereumNetwork.ARBITRUM: "https://api.arbiscan.io", + EthereumNetwork.ARBITRUM_NOVA: "https://api-nova.arbiscan.io", + EthereumNetwork.ARBITRUM_GOERLI: "https://api-goerli.arbiscan.io", EthereumNetwork.AVALANCHE: "https://api.snowtrace.io", EthereumNetwork.MOON_MOONBEAM: "https://api-moonbeam.moonscan.io", EthereumNetwork.MOON_MOONRIVER: "https://api-moonriver.moonscan.io", EthereumNetwork.MOON_MOONBASE: "https://api-moonbase.moonscan.io", EthereumNetwork.CRONOS_MAINNET: "https://api.cronoscan.com", EthereumNetwork.CRONOS_TESTNET: "https://api-testnet.cronoscan.com", + EthereumNetwork.CELO: "https://api.celoscan.io", } HTTP_HEADERS = { "User-Agent": "curl/7.77.0", diff --git a/gnosis/eth/django/models.py b/gnosis/eth/django/models.py index 5bbf28721..b59bea59f 100644 --- a/gnosis/eth/django/models.py +++ b/gnosis/eth/django/models.py @@ -236,6 +236,9 @@ def get_prep_value(self, value: Union[bytes, str]) -> Optional[bytes]: if value: return self._to_bytes(value) + def value_to_string(self, obj): + return str(self.value_from_object(obj)) + def to_python(self, value) -> Optional[str]: if value is not None: try: diff --git a/gnosis/eth/django/tests/test_models.py b/gnosis/eth/django/tests/test_models.py index 5a3b17141..42b4754e8 100644 --- a/gnosis/eth/django/tests/test_models.py +++ b/gnosis/eth/django/tests/test_models.py @@ -1,4 +1,5 @@ from django.core.exceptions import ValidationError +from django.core.serializers import serialize from django.db import DataError, transaction from django.test import TestCase @@ -146,3 +147,40 @@ def test_keccak256_field(self): ): with transaction.atomic(): Keccak256Hash.objects.create(value=value_hex_invalid) + + def test_serialize_keccak256_field_to_json(self): + hexvalue: str = ( + "0xdb5b7c6d3b0cc538a5859afc4674a785d9d111c3835390295f3d3173d41ca8ea" + ) + Keccak256Hash.objects.create(value=hexvalue) + serialized = serialize("json", Keccak256Hash.objects.all()) + # hexvalue should be in serialized data + self.assertIn(hexvalue, serialized) + + def test_serialize_ethereum_address_field_to_json(self): + address: str = "0x5aFE3855358E112B5647B952709E6165e1c1eEEe" + EthereumAddress.objects.create(value=address) + serialized = serialize("json", EthereumAddress.objects.all()) + # address should be in serialized data + self.assertIn(address, serialized) + + def test_serialize_ethereum_address_v2_field_to_json(self): + address: str = "0x5aFE3855358E112B5647B952709E6165e1c1eEEe" + EthereumAddressV2.objects.create(value=address) + serialized = serialize("json", EthereumAddressV2.objects.all()) + # address should be in serialized data + self.assertIn(address, serialized) + + def test_serialize_uint256_field_to_json(self): + value = 2**260 + Uint256.objects.create(value=value) + serialized = serialize("json", Uint256.objects.all()) + # value should be in serialized data + self.assertIn(str(value), serialized) + + def test_serialize_sha3_hash_to_json(self): + hash = Web3.keccak(text="testSerializer") + Sha3Hash.objects.create(value=hash) + serialized = serialize("json", Sha3Hash.objects.all()) + # hash should be in serialized data + self.assertIn(hash.hex(), serialized) diff --git a/gnosis/eth/eip712/__init__.py b/gnosis/eth/eip712/__init__.py new file mode 100644 index 000000000..171524e69 --- /dev/null +++ b/gnosis/eth/eip712/__init__.py @@ -0,0 +1,192 @@ +""" +Based on https://github.com/jvinet/eip712, adjustments by https://github.com/uxio0 + +Routines for EIP712 encoding and signing. + +Copyright (C) 2022 Judd Vinet + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import re +from typing import Any, Dict, List, Union + +from eth_abi import encode_abi +from eth_account import Account +from eth_typing import Hash32, HexStr +from hexbytes import HexBytes + +from ..utils import fast_keccak + + +def encode_data(primary_type: str, data, types): + """ + Encode structured data as per Ethereum's signTypeData_v4. + + https://docs.metamask.io/guide/signing-data.html#sign-typed-data-v4 + + This code is ported from the Javascript "eth-sig-util" package. + """ + encoded_types = ["bytes32"] + encoded_values = [hash_type(primary_type, types)] + + def _encode_field(name, typ, value): + if typ in types: + if value is None: + return [ + "bytes32", + "0x0000000000000000000000000000000000000000000000000000000000000000", + ] + else: + return ["bytes32", fast_keccak(encode_data(typ, value, types))] + + if value is None: + raise Exception(f"Missing value for field {name} of type {type}") + + # Accept string bytes + if "bytes" in typ and isinstance(value, str): + value = HexBytes(value) + + # Accept string uint and int + if "int" in typ and isinstance(value, str): + value = int(value) + + if typ == "bytes": + return ["bytes32", fast_keccak(value)] + + if typ == "string": + # Convert string to bytes. + value = value.encode("utf-8") + return ["bytes32", fast_keccak(value)] + + if typ.endswith("]"): + parsed_type = typ[:-2] + type_value_pairs = dict( + [_encode_field(name, parsed_type, v) for v in value] + ) + h = fast_keccak( + encode_abi( + list(type_value_pairs.keys()), list(type_value_pairs.values()) + ) + ) + return ["bytes32", h] + + return [typ, value] + + for field in types[primary_type]: + typ, val = _encode_field(field["name"], field["type"], data[field["name"]]) + encoded_types.append(typ) + encoded_values.append(val) + + return encode_abi(encoded_types, encoded_values) + + +def encode_type(primary_type: str, types) -> str: + result = "" + deps = find_type_dependencies(primary_type, types) + deps = sorted([d for d in deps if d != primary_type]) + deps = [primary_type] + deps + for typ in deps: + children = types[typ] + if not children: + raise Exception(f"No type definition specified: {type}") + + defs = [f"{t['type']} {t['name']}" for t in types[typ]] + result += typ + "(" + ",".join(defs) + ")" + return result + + +def find_type_dependencies(primary_type: str, types, results=None): + if results is None: + results = [] + + primary_type = re.split(r"\W", primary_type)[0] + if primary_type in results or not types.get(primary_type): + return results + results.append(primary_type) + + for field in types[primary_type]: + deps = find_type_dependencies(field["type"], types, results) + for dep in deps: + if dep not in results: + results.append(dep) + + return results + + +def hash_type(primary_type: str, types) -> Hash32: + return fast_keccak(encode_type(primary_type, types).encode()) + + +def hash_struct(primary_type: str, data, types) -> Hash32: + return fast_keccak(encode_data(primary_type, data, types)) + + +def eip712_encode(typed_data: Dict[str, Any]) -> List[bytes]: + """ + Given a dict of structured data and types, return a 3-element list of + the encoded, signable data. + + 0: The magic & version (0x1901) + 1: The encoded types + 2: The encoded data + """ + try: + parts = [ + bytes.fromhex("1901"), + hash_struct("EIP712Domain", typed_data["domain"], typed_data["types"]), + ] + if typed_data["primaryType"] != "EIP712Domain": + parts.append( + hash_struct( + typed_data["primaryType"], + typed_data["message"], + typed_data["types"], + ) + ) + return parts + except (KeyError, AttributeError, TypeError, IndexError) as exc: + raise ValueError(f"Not valid {typed_data}") from exc + + +def eip712_encode_hash(typed_data: Dict[str, Any]) -> Hash32: + """ + :param typed_data: EIP712 structured data and types + :return: Keccak256 hash of encoded signable data + """ + return fast_keccak(b"".join(eip712_encode(typed_data))) + + +def eip712_signature( + payload: Dict[str, Any], private_key: Union[HexStr, bytes] +) -> bytes: + """ + Given a bytes object and a private key, return a signature suitable for + EIP712 and EIP191 messages. + """ + if isinstance(payload, (list, tuple)): + payload = b"".join(payload) + + if isinstance(private_key, str) and private_key.startswith("0x"): + private_key = private_key[2:] + elif isinstance(private_key, bytes): + private_key = bytes.hex() + + account = Account.from_key(private_key) + hashed_payload = fast_keccak(payload) + return account.signHash(hashed_payload)["signature"] diff --git a/gnosis/eth/ethereum_client.py b/gnosis/eth/ethereum_client.py index 9955dc888..59f5f4d61 100644 --- a/gnosis/eth/ethereum_client.py +++ b/gnosis/eth/ethereum_client.py @@ -1,6 +1,6 @@ import os from enum import Enum -from functools import wraps +from functools import cached_property, wraps from logging import getLogger from typing import ( Any, @@ -59,7 +59,7 @@ fast_to_checksum_address, mk_contract_address, ) -from gnosis.util import cache, cached_property, chunks +from gnosis.util import cache, chunks from .constants import ( ERC20_721_TRANSFER_TOPIC, diff --git a/gnosis/eth/ethereum_network.py b/gnosis/eth/ethereum_network.py index b5093efc7..6193ef50a 100644 --- a/gnosis/eth/ethereum_network.py +++ b/gnosis/eth/ethereum_network.py @@ -100,6 +100,7 @@ class EthereumNetwork(Enum): TAO_CORE = 558 METIS_TESTNET = 588 MACA_TESTNET = 595 + METIS_GOERLI_TESTNET = 599 KAR = 686 FETH_FACTORY127_TESTNET = 721 CHEAPETH_CHEAPNET = 777 @@ -126,8 +127,11 @@ class EthereumNetwork(Enum): MOON_MOONSHADOW = 1288 GANACHE = 1337 CATECHAIN = 1618 + RABBIT = 1807 EURUS_TESTNET = 1984 EGEM = 1987 + PUBLICMINT_TESTNET = 2019 + PUBLICMINT_MAINNET = 2020 EDG = 2021 EDG_BERESHEET = 2022 KORTHO = 2559 @@ -176,6 +180,7 @@ class EthereumNetwork(Enum): SPARTA_TESTNET = 333888 OLYMPUS = 333999 ARBITRUM_TESTNET = 421611 + ARBITRUM_GOERLI = 421613 ETHO = 1313114 XERO = 1313500 MUSIC = 7762959 @@ -308,7 +313,7 @@ class EthereumNetwork(Enum): FREIGHT_TRUST_NETWORK = 211 PERMISSION = 222 SUR_BLOCKCHAIN_NETWORK = 262 - KCC_MAINET = 321 + KCC_MAINNET = 321 CALLISTO_MAINNET = 820 WORLD_TRADE_TECHNICAL_BLOCKCHAIN = 1202 ATHEIOS = 1620 @@ -329,7 +334,9 @@ class EthereumNetwork(Enum): EVANESCO_TESTNET = 1201 SINGULARITY_ZERO_TESTNET = 12051 OYCHAIN_TESTNET = 125 + BOBA_NETWORK_BOBABEAM = 1294 BOBA_NETWORK_BOBABASE = 1297 + BOBA_AVAX_L2 = 43288 ETND_CHAIN_MAINNETS = 131419 AITD_MAINNET = 1319 AITD_TESTNET = 1320 @@ -354,6 +361,7 @@ class EthereumNetwork(Enum): HARADEV_TESTNET = 197710212031 MILKOMEDA_C1_MAINNET = 2001 MILKOMEDA_C1_TESTNET = 200101 + MILKOMEDA_A1_MAINNET = 2002 MILKOMEDA_A1_TESTNET = 200202 CLOUDWALK_MAINNET = 2009 CLOUDWALK_TESTNET = 2008 diff --git a/gnosis/eth/multicall.py b/gnosis/eth/multicall.py index 1d0ac3059..ac67357d9 100644 --- a/gnosis/eth/multicall.py +++ b/gnosis/eth/multicall.py @@ -50,6 +50,8 @@ class Multicall: EthereumNetwork.RINKEBY: "0x5BA1e12693Dc8F9c48aAD8770482f4739bEeD696", EthereumNetwork.ROPSTEN: "0x5BA1e12693Dc8F9c48aAD8770482f4739bEeD696", EthereumNetwork.XDAI: "0x08612d3C4A5Dfe2FaaFaFe6a4ff712C2dC675bF7", + EthereumNetwork.KCC_MAINNET: "0x7C1C85C39d3D6b6ecB811dfe949B9C23f6E818B0", + EthereumNetwork.KCC_TESTNET: "0x665683D9bd41C09cF38c3956c926D9924F1ADa97", } def __init__( diff --git a/gnosis/eth/oracles/__init__.py b/gnosis/eth/oracles/__init__.py index ee2b94310..72de3fe3d 100644 --- a/gnosis/eth/oracles/__init__.py +++ b/gnosis/eth/oracles/__init__.py @@ -20,10 +20,10 @@ UnderlyingToken, UniswapOracle, UniswapV2Oracle, - UsdPricePoolOracle, YearnOracle, ZerionComposedOracle, ) +from .superfluid import SuperfluidOracle from .sushiswap import SushiswapOracle from .uniswap_v3 import UniswapV3Oracle @@ -45,7 +45,7 @@ "UniswapOracle", "UniswapV2Oracle", "UniswapV3Oracle", - "UsdPricePoolOracle", "YearnOracle", "ZerionComposedOracle", + "SuperfluidOracle", ] diff --git a/gnosis/eth/oracles/abis/superfluid_abis.py b/gnosis/eth/oracles/abis/superfluid_abis.py new file mode 100644 index 000000000..510b8b8d6 --- /dev/null +++ b/gnosis/eth/oracles/abis/superfluid_abis.py @@ -0,0 +1,9 @@ +super_token_abi = [ + { + "inputs": [], + "name": "getUnderlyingToken", + "outputs": [{"internalType": "address", "name": "", "type": "address"}], + "stateMutability": "view", + "type": "function", + }, +] diff --git a/gnosis/eth/oracles/cowswap.py b/gnosis/eth/oracles/cowswap.py index a70767a9d..947cce643 100644 --- a/gnosis/eth/oracles/cowswap.py +++ b/gnosis/eth/oracles/cowswap.py @@ -56,10 +56,14 @@ def get_price( result = self.api.get_estimated_amount( token_address_1, token_address_2, OrderKind.SELL, 10**token_1_decimals ) - if "amount" in result: + if "buyAmount" in result: # Decimals needs to be adjusted token_2_decimals = get_decimals(token_address_2, self.ethereum_client) - return float(result["amount"]) / 10**token_2_decimals + return ( + float(result["buyAmount"]) + / result["sellAmount"] + * 10 ** (token_1_decimals - token_2_decimals) + ) exception = None except IOError as exc: diff --git a/gnosis/eth/oracles/kyber.py b/gnosis/eth/oracles/kyber.py index db265fcef..53ba3a70b 100644 --- a/gnosis/eth/oracles/kyber.py +++ b/gnosis/eth/oracles/kyber.py @@ -1,11 +1,10 @@ import logging +from functools import cached_property from typing import Optional from eth_abi.exceptions import DecodingError from web3.exceptions import BadFunctionCallOutput -from gnosis.util import cached_property - from .. import EthereumClient, EthereumNetwork from ..contracts import get_kyber_network_proxy_contract from .exceptions import CannotGetPriceFromOracle, InvalidPriceFromOracle @@ -44,6 +43,17 @@ def __init__( self.w3 = ethereum_client.w3 self._kyber_network_proxy_address = kyber_network_proxy_address + @classmethod + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if Oracle is available for the EthereumClient provided, `False` otherwise + """ + return ethereum_client.get_network() in cls.ADDRESSES + @cached_property def kyber_network_proxy_address(self): if self._kyber_network_proxy_address: diff --git a/gnosis/eth/oracles/oracles.py b/gnosis/eth/oracles/oracles.py index 00370d135..0acbf9bda 100644 --- a/gnosis/eth/oracles/oracles.py +++ b/gnosis/eth/oracles/oracles.py @@ -2,6 +2,7 @@ import logging from abc import ABC, abstractmethod from dataclasses import dataclass +from functools import cached_property from typing import List, Optional, Tuple import requests @@ -12,8 +13,6 @@ from web3.contract import Contract from web3.exceptions import BadFunctionCallOutput -from gnosis.util import cached_property - from .. import EthereumClient, EthereumNetwork from ..constants import NULL_ADDRESS from ..contracts import ( @@ -42,31 +41,45 @@ class UnderlyingToken: quantity: int -class PriceOracle(ABC): +class BaseOracle(ABC): + @classmethod @abstractmethod - def get_price(self, *args) -> float: - pass + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if Oracle is available for the EthereumClient provided, `False` otherwise + """ + raise NotImplementedError -class PricePoolOracle(ABC): +class PriceOracle(BaseOracle): @abstractmethod - def get_pool_token_price(self, pool_token_address: ChecksumAddress) -> float: - pass + def get_price(self, *args) -> float: + raise NotImplementedError -class UsdPricePoolOracle(ABC): +class PricePoolOracle(BaseOracle): @abstractmethod - def get_pool_usd_token_price(self, pool_token_address: ChecksumAddress) -> float: - pass + def get_pool_token_price(self, pool_token_address: ChecksumAddress) -> float: + raise NotImplementedError -class ComposedPriceOracle(ABC): +class ComposedPriceOracle(BaseOracle): @abstractmethod def get_underlying_tokens(self, *args) -> List[Tuple[UnderlyingToken]]: - pass + raise NotImplementedError class UniswapOracle(PriceOracle): + """ + Uniswap V1 Oracle + + https://docs.uniswap.org/protocol/V1/guides/connect-to-uniswap + """ + ADDRESSES = { EthereumNetwork.MAINNET: "0xc0a47dFe034B400B47bDaD5FecDa2621de6c4d95", EthereumNetwork.RINKEBY: "0xf5D915570BC477f9B8D6C0E980aA81757A3AaC36", @@ -88,6 +101,17 @@ def __init__( self.w3 = ethereum_client.w3 self._uniswap_factory_address = uniswap_factory_address + @classmethod + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if Oracle is available for the EthereumClient provided, `False` otherwise + """ + return ethereum_client.get_network() in cls.ADDRESSES + @cached_property def uniswap_factory_address(self): if self._uniswap_factory_address: @@ -231,6 +255,22 @@ def __init__( ethereum_client.w3, self.router_address ) + @classmethod + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if Oracle is available for the EthereumClient provided, `False` otherwise + """ + return ethereum_client.is_contract( + cls.ROUTER_ADDRESSES.get( + ethereum_client.get_network(), + cls.ROUTER_ADDRESSES[EthereumNetwork.MAINNET], + ) + ) + @cached_property def factory(self): return get_uniswap_v2_factory_contract( @@ -436,6 +476,17 @@ def __init__(self, ethereum_client: EthereumClient, price_oracle: PriceOracle): self.w3 = ethereum_client.w3 self.price_oracle = price_oracle + @classmethod + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if Oracle is available for the EthereumClient provided, `False` otherwise + """ + return ethereum_client.get_network() == EthereumNetwork.MAINNET + def get_price(self, token_address: str) -> float: if ( token_address == "0x4da27a545c0c5B758a6BA100e3a049001de870f5" @@ -467,6 +518,17 @@ def __init__(self, ethereum_client: EthereumClient, price_oracle: PriceOracle): self.w3 = ethereum_client.w3 self.price_oracle = price_oracle + @classmethod + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if Oracle is available for the EthereumClient provided, `False` otherwise + """ + return ethereum_client.get_network() == EthereumNetwork.MAINNET + def get_price(self, token_address: str) -> float: try: underlying_token = ( @@ -504,6 +566,17 @@ def __init__( self.ethereum_client = ethereum_client self.w3 = ethereum_client.w3 + @classmethod + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if Oracle is available for the EthereumClient provided, `False` otherwise + """ + return ethereum_client.get_network() == EthereumNetwork.MAINNET + @cached_property def zerion_adapter_contract(self) -> Optional[Contract]: """ @@ -626,6 +699,17 @@ def __init__( ethereum_client, iearn_token_adapter ) + @classmethod + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if Oracle is available for the EthereumClient provided, `False` otherwise + """ + return ethereum_client.get_network() == EthereumNetwork.MAINNET + def get_underlying_tokens( self, token_address: ChecksumAddress ) -> List[Tuple[float, ChecksumAddress]]: @@ -661,6 +745,17 @@ def __init__(self, ethereum_client: EthereumClient, price_oracle: PriceOracle): self.w3 = ethereum_client.w3 self.price_oracle = price_oracle + @classmethod + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if Oracle is available for the EthereumClient provided, `False` otherwise + """ + return ethereum_client.get_network() == EthereumNetwork.MAINNET + def get_pool_token_price(self, pool_token_address: ChecksumAddress) -> float: """ Estimate balancer pool token price based on its components diff --git a/gnosis/eth/oracles/superfluid.py b/gnosis/eth/oracles/superfluid.py new file mode 100644 index 000000000..57544abba --- /dev/null +++ b/gnosis/eth/oracles/superfluid.py @@ -0,0 +1,47 @@ +from eth_abi.exceptions import DecodingError +from web3.exceptions import BadFunctionCallOutput + +from .. import EthereumClient, EthereumNetwork +from .abis.superfluid_abis import super_token_abi +from .exceptions import CannotGetPriceFromOracle +from .oracles import PriceOracle + + +class SuperfluidOracle(PriceOracle): + def __init__(self, ethereum_client: EthereumClient, price_oracle: PriceOracle): + """ + :param ethereum_client: + :param price_oracle: Price oracle to get the price for the components of Superfluid Tokens + """ + self.ethereum_client = ethereum_client + self.w3 = ethereum_client.w3 + self.price_oracle = price_oracle + + @classmethod + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if Oracle is available for the EthereumClient provided, `False` otherwise + """ + return ethereum_client.get_network() in ( + EthereumNetwork.MATIC, + EthereumNetwork.XDAI, + EthereumNetwork.ARBITRUM, + EthereumNetwork.OPTIMISTIC, + ) + + def get_price(self, token_address: str) -> float: + try: + underlying_token = ( + self.w3.eth.contract(token_address, abi=super_token_abi) + .functions.getUnderlyingToken() + .call() + ) + return self.price_oracle.get_price(underlying_token) + except (ValueError, BadFunctionCallOutput, DecodingError): + raise CannotGetPriceFromOracle( + f"Cannot get price for {token_address}. It is not a wrapper Super Token" + ) diff --git a/gnosis/eth/oracles/uniswap_v3.py b/gnosis/eth/oracles/uniswap_v3.py index 8281343ac..702666165 100644 --- a/gnosis/eth/oracles/uniswap_v3.py +++ b/gnosis/eth/oracles/uniswap_v3.py @@ -1,5 +1,6 @@ import functools import logging +from functools import cached_property from typing import Optional from eth_abi.exceptions import DecodingError @@ -7,10 +8,9 @@ from web3.contract import Contract from web3.exceptions import BadFunctionCallOutput -from gnosis.util import cached_property - from .. import EthereumClient from ..constants import NULL_ADDRESS +from ..contracts import get_erc20_contract from .abis.uniswap_v3 import ( uniswap_v3_factory_abi, uniswap_v3_pool_abi, @@ -138,26 +138,48 @@ def get_price( f"Uniswap V3 pool does not exist for {token_address} and {token_address_2}" ) - pool_contract = self.w3.eth.contract(pool_address, abi=uniswap_v3_pool_abi) + # Decimals needs to be adjusted + token_decimals = get_decimals(token_address, self.ethereum_client) + token_2_decimals = get_decimals(token_address_2, self.ethereum_client) + pool_contract = self.w3.eth.contract(pool_address, abi=uniswap_v3_pool_abi) try: - sqrt_price_x96, _, _, _, _, _, _ = pool_contract.functions.slot0().call() + ( + token_balance, + token_2_balance, + (sqrt_price_x96, _, _, _, _, _, _), + ) = self.ethereum_client.batch_call( + [ + get_erc20_contract( + self.ethereum_client.w3, token_address + ).functions.balanceOf(pool_address), + get_erc20_contract( + self.ethereum_client.w3, token_address_2 + ).functions.balanceOf(pool_address), + pool_contract.functions.slot0(), + ] + ) + if (token_balance / 10**token_decimals) < 2 or ( + token_2_balance / 10**token_2_decimals + ) < 2: + error_message = ( + f"Not enough liquidity on uniswap v3 for pair token_1={token_address} " + f"token_2={token_address_2}, at least 2 units of each token are required" + ) + logger.warning(error_message) + raise CannotGetPriceFromOracle(error_message) except ( ValueError, BadFunctionCallOutput, DecodingError, ) as e: error_message = ( - f"Cannot get uniswap v2 price for pair token_1={token_address} " + f"Cannot get uniswap v3 price for pair token_1={token_address} " f"token_2={token_address_2}" ) logger.warning(error_message) raise CannotGetPriceFromOracle(error_message) from e - # Decimals needs to be adjusted - token_decimals = get_decimals(token_address, self.ethereum_client) - token_2_decimals = get_decimals(token_address_2, self.ethereum_client) - # https://docs.uniswap.org/sdk/guides/fetching-prices if not reversed: # Multiplying by itself is way faster than exponential diff --git a/gnosis/eth/tests/eip712/__init__.py b/gnosis/eth/tests/eip712/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/gnosis/eth/tests/eip712/test_eip712.py b/gnosis/eth/tests/eip712/test_eip712.py new file mode 100644 index 000000000..5452d39a5 --- /dev/null +++ b/gnosis/eth/tests/eip712/test_eip712.py @@ -0,0 +1,136 @@ +from unittest import TestCase + +from gnosis.eth.eip712 import eip712_encode_hash + + +class TestEIP712(TestCase): + address = "0x8e12f01DAE5FE7f1122Dc42f2cB084F2f9E8aA03" + types = { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "Mailbox": [ + {"name": "owner", "type": "address"}, + {"name": "messages", "type": "Message[]"}, + ], + "Message": [ + {"name": "sender", "type": "address"}, + {"name": "subject", "type": "string"}, + {"name": "isSpam", "type": "bool"}, + {"name": "body", "type": "string"}, + ], + } + + msgs = [ + { + "sender": address, + "subject": "Hello World", + "body": "The sparrow flies at midnight.", + "isSpam": False, + }, + { + "sender": address, + "subject": "You may have already Won! :dumb-emoji:", + "body": "Click here for sweepstakes!", + "isSpam": True, + }, + ] + + mailbox = {"owner": address, "messages": msgs} + + def test_eip712_encode_hash(self): + for value in [ + {}, + None, + ]: + with self.assertRaises(ValueError): + eip712_encode_hash(value) + + wrong_types = { + "EIP712Domain": [ + {"name": "name", "type": "stringa"}, + {"name": "version", "type": "bstring"}, + {"name": "chainId", "type": "aaauint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "Mailbox": [ + {"name": "owner", "type": "address"}, + {"name": "messages", "type": "Message[]"}, + ], + "Message": [ + {"name": "sender", "type": "address"}, + {"name": "subject", "type": "string"}, + {"name": "isSpam", "type": "bool"}, + {"name": "body", "type": "string"}, + ], + } + + payload = { + "types": wrong_types, + "primaryType": "Mailbox", + "domain": { + "name": "MyDApp", + "version": "3.0", + "chainId": 41, + "verifyingContract": self.address, + }, + "message": self.mailbox, + } + + with self.assertRaises(ValueError): + eip712_encode_hash(payload) + + payload["types"] = self.types + self.assertEqual( + eip712_encode_hash(payload).hex(), + "d54ecb6637fa990aae0286d420ac70658db995ae6d09cc9bb1dacf364a1417d0", + ) + + def test_eip712_encode_hash_string_uint(self): + # test string uint (chainId) + payload = { + "types": self.types, + "primaryType": "Mailbox", + "domain": { + "name": "MyDApp", + "version": "3.0", + "chainId": "41", + "verifyingContract": self.address, + }, + "message": self.mailbox, + } + + self.assertEqual( + eip712_encode_hash(payload).hex(), + "d54ecb6637fa990aae0286d420ac70658db995ae6d09cc9bb1dacf364a1417d0", + ) + + def test_eip712_encode_hash_string_bytes(self): + types_with_bytes = { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + ], + "Message": [ + {"name": "oneByte", "type": "bytes1"}, + {"name": "maxByte", "type": "bytes32"}, + ], + } + payload = { + "types": types_with_bytes, + "primaryType": "Message", + "domain": { + "name": "MyDApp", + }, + "message": { + "oneByte": "0x01", + "maxByte": "0x6214da6089b8d8aaa6e6268977746aa0af19fd1ef5d56e225bb3390a697c3ec1", + }, + } + + self.assertEqual( + eip712_encode_hash(payload).hex(), + "2950cf06416c6c20059f24a965e3baf51a24f4ef49a1e7b1a47ee13ee08cde1f", + ) diff --git a/gnosis/eth/tests/mocks/mock_trace_block.py b/gnosis/eth/tests/mocks/mock_trace_block.py index edd23cf1e..06aaaa2ae 100644 --- a/gnosis/eth/tests/mocks/mock_trace_block.py +++ b/gnosis/eth/tests/mocks/mock_trace_block.py @@ -179,7 +179,7 @@ "blockHash": "0x8f9809f6012f85803956a419e2e54914dfdebba33e4f7a0d1574b12e92499c0e", "blockNumber": 13191781, "error": "Reverted", - "result": None, + "result": {"gasUsed": 27856, "output": HexBytes("0x")}, "subtraces": 0, "traceAddress": [], "transactionHash": "0xbe99757628bfc3d5c7ee4e42c2629ddd13ac52354e6abb189efe5e277dce05b3", @@ -746,7 +746,12 @@ "blockHash": "0x8f9809f6012f85803956a419e2e54914dfdebba33e4f7a0d1574b12e92499c0e", "blockNumber": 13191781, "error": "Reverted", - "result": None, + "result": { + "gasUsed": 16268, + "output": HexBytes( + "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002b556e69737761705632526f757465723a20494e53554646494349454e545f4f55545055545f414d4f554e54000000000000000000000000000000000000000000" + ), + }, "subtraces": 2, "traceAddress": [], "transactionHash": "0xfac7403428a8213f3fc296412eb3f259086d80dd83be2d819b574b145b8d4855", @@ -889,7 +894,12 @@ "blockHash": "0x8f9809f6012f85803956a419e2e54914dfdebba33e4f7a0d1574b12e92499c0e", "blockNumber": 13191781, "error": "Reverted", - "result": None, + "result": { + "gasUsed": 93884, + "output": HexBytes( + "0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000164d696e2072657475726e206e6f742072656163686564" + ), + }, "subtraces": 5, "traceAddress": [], "transactionHash": "0xd1118a18e43777636ccef0cafa5de58c3b0c6800454606342ba46a662828a8c6", diff --git a/gnosis/eth/tests/mocks/mock_trace_transaction.py b/gnosis/eth/tests/mocks/mock_trace_transaction.py index 42e5d8530..3cafa67e9 100644 --- a/gnosis/eth/tests/mocks/mock_trace_transaction.py +++ b/gnosis/eth/tests/mocks/mock_trace_transaction.py @@ -72,4 +72,386 @@ "type": "call", } ], + "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0": [ + { + "action": { + "from": "0x3F478216041713A4B1EcB672515cc1b039BBE790", + "gas": 274494, + "value": 0, + "callType": "call", + "input": HexBytes( + "0x6a76120200000000000000000000000040a2accbd92bca938b02010e17a5b8929b49130d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000003c2f80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064000000000000000000000000000000000000000000000000000000000000004c48d80ff0a0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000046b00469788fe6e9e9681c6ebf3bf78e7fd26fc01544600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044bd86e508736166652e657468000000000000000000000000000000000000000000000000000000000000000000000000d662e05ce522b3861b70fc376f60bf50e200abfa00a0b937d5c8e32a80e3a8ed4227cd020221544ee6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002c4bf6213e4000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000005bacaa20000000000000000000000000000000000000000000000012dfc9a7680524000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000100142b9e197e79ca94c077397bec601d9758e6ad54bfb308c529fb1ccb649664629507b80014ff96094ee587f52aed4ea98ccdce3ed4cef1139f4cbc45abf4cf3efca2d62b9a7cace73dca2d91ef5456d3fcecc2549d7cfeb33bf495a2505190b3cc6a55a90d3f5817150ad9c8d4fb3c6157c5ab99a2bd90bde5385608e212b9f0c0eb5e6c10b114900b7f894fcd9ba6126d9495341e2b09a684d822a04ba9e3bf40757bdb25323893eeac40649684dae8d5ac9be1b5688df2c8c77ffb1df3a8920c9493e26704a190c539948597a25dd0eadc3e9d3c04eef5e8bbb84dff18696e8fd0248ce2387d7c823f3b28280a73edcfd1b00d103210f5ca8f817b8c90519736c3b51ea1b6472ba406c4456d5b01c3b59343e564aae747fdd47ff6dc8ff4629d28252544a7e92977dccd4b30fb02073f088b2ad1cf0b7f5b2205842f5a78ba2580459420e11a06505b18e5ee6f8ded5bb92ee903d2eaf5d63bbecd73e213ca0870e463abf45cb18f85a835919099f45c6c56581232b3be9c06448420b35c94f4f3628f31b808b09ced67b073ab97919ba14e9389e154be4fe2b10fcdd327302fdc9a0b8cea5373715d2fce8d9ddd244f4259aafb0a21305edfdda956dd84359d69bacd790ff82f7310dd382bf3f53e885e74f5dad9b3aeb7fe96ce926ff47f8e2791b9d07b3e620189a36bf17e30c874e66b205c79492a16cd5a9c5bb65b700a0b937d5c8e32a80e3a8ed4227cd020221544ee6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640087b83f9aef04d6a9b38aab1023696e3b35434a8ba4c1611b39074722e31473dd333d27000000000000000000000000826f446c587159897db0ae01192da1691f12007f0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000410ff58feb0cba6ef7e22c415a7936b8a81fa384ebc5d2ec3718cc5e72730ddcdd296b0b29dd3aba9c64edde30f65ea98066a2cfa3412f9877d6bf3fded867ffd01b00000000000000000000000000000000000000000000000000000000000000" + ), + "to": "0x826f446C587159897Db0aE01192dA1691f12007f", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 228534, + "output": HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000001" + ), + }, + "subtraces": 1, + "traceAddress": [], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0x826f446C587159897Db0aE01192dA1691f12007f", + "gas": 265117, + "value": 0, + "callType": "delegatecall", + "input": HexBytes( + "0x6a76120200000000000000000000000040a2accbd92bca938b02010e17a5b8929b49130d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000003c2f80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064000000000000000000000000000000000000000000000000000000000000004c48d80ff0a0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000046b00469788fe6e9e9681c6ebf3bf78e7fd26fc01544600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044bd86e508736166652e657468000000000000000000000000000000000000000000000000000000000000000000000000d662e05ce522b3861b70fc376f60bf50e200abfa00a0b937d5c8e32a80e3a8ed4227cd020221544ee6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002c4bf6213e4000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000005bacaa20000000000000000000000000000000000000000000000012dfc9a7680524000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000100142b9e197e79ca94c077397bec601d9758e6ad54bfb308c529fb1ccb649664629507b80014ff96094ee587f52aed4ea98ccdce3ed4cef1139f4cbc45abf4cf3efca2d62b9a7cace73dca2d91ef5456d3fcecc2549d7cfeb33bf495a2505190b3cc6a55a90d3f5817150ad9c8d4fb3c6157c5ab99a2bd90bde5385608e212b9f0c0eb5e6c10b114900b7f894fcd9ba6126d9495341e2b09a684d822a04ba9e3bf40757bdb25323893eeac40649684dae8d5ac9be1b5688df2c8c77ffb1df3a8920c9493e26704a190c539948597a25dd0eadc3e9d3c04eef5e8bbb84dff18696e8fd0248ce2387d7c823f3b28280a73edcfd1b00d103210f5ca8f817b8c90519736c3b51ea1b6472ba406c4456d5b01c3b59343e564aae747fdd47ff6dc8ff4629d28252544a7e92977dccd4b30fb02073f088b2ad1cf0b7f5b2205842f5a78ba2580459420e11a06505b18e5ee6f8ded5bb92ee903d2eaf5d63bbecd73e213ca0870e463abf45cb18f85a835919099f45c6c56581232b3be9c06448420b35c94f4f3628f31b808b09ced67b073ab97919ba14e9389e154be4fe2b10fcdd327302fdc9a0b8cea5373715d2fce8d9ddd244f4259aafb0a21305edfdda956dd84359d69bacd790ff82f7310dd382bf3f53e885e74f5dad9b3aeb7fe96ce926ff47f8e2791b9d07b3e620189a36bf17e30c874e66b205c79492a16cd5a9c5bb65b700a0b937d5c8e32a80e3a8ed4227cd020221544ee6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640087b83f9aef04d6a9b38aab1023696e3b35434a8ba4c1611b39074722e31473dd333d27000000000000000000000000826f446c587159897db0ae01192da1691f12007f0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000410ff58feb0cba6ef7e22c415a7936b8a81fa384ebc5d2ec3718cc5e72730ddcdd296b0b29dd3aba9c64edde30f65ea98066a2cfa3412f9877d6bf3fded867ffd01b00000000000000000000000000000000000000000000000000000000000000" + ), + "to": "0xb6029EA3B2c51D09a50B53CA8012FeEB05bDa35A", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 223320, + "output": HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000001" + ), + }, + "subtraces": 1, + "traceAddress": [0], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0x826f446C587159897Db0aE01192dA1691f12007f", + "gas": 239379, + "value": 0, + "callType": "delegatecall", + "input": HexBytes( + "0x8d80ff0a0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000046b00469788fe6e9e9681c6ebf3bf78e7fd26fc01544600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044bd86e508736166652e657468000000000000000000000000000000000000000000000000000000000000000000000000d662e05ce522b3861b70fc376f60bf50e200abfa00a0b937d5c8e32a80e3a8ed4227cd020221544ee6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002c4bf6213e4000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000005bacaa20000000000000000000000000000000000000000000000012dfc9a7680524000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000100142b9e197e79ca94c077397bec601d9758e6ad54bfb308c529fb1ccb649664629507b80014ff96094ee587f52aed4ea98ccdce3ed4cef1139f4cbc45abf4cf3efca2d62b9a7cace73dca2d91ef5456d3fcecc2549d7cfeb33bf495a2505190b3cc6a55a90d3f5817150ad9c8d4fb3c6157c5ab99a2bd90bde5385608e212b9f0c0eb5e6c10b114900b7f894fcd9ba6126d9495341e2b09a684d822a04ba9e3bf40757bdb25323893eeac40649684dae8d5ac9be1b5688df2c8c77ffb1df3a8920c9493e26704a190c539948597a25dd0eadc3e9d3c04eef5e8bbb84dff18696e8fd0248ce2387d7c823f3b28280a73edcfd1b00d103210f5ca8f817b8c90519736c3b51ea1b6472ba406c4456d5b01c3b59343e564aae747fdd47ff6dc8ff4629d28252544a7e92977dccd4b30fb02073f088b2ad1cf0b7f5b2205842f5a78ba2580459420e11a06505b18e5ee6f8ded5bb92ee903d2eaf5d63bbecd73e213ca0870e463abf45cb18f85a835919099f45c6c56581232b3be9c06448420b35c94f4f3628f31b808b09ced67b073ab97919ba14e9389e154be4fe2b10fcdd327302fdc9a0b8cea5373715d2fce8d9ddd244f4259aafb0a21305edfdda956dd84359d69bacd790ff82f7310dd382bf3f53e885e74f5dad9b3aeb7fe96ce926ff47f8e2791b9d07b3e620189a36bf17e30c874e66b205c79492a16cd5a9c5bb65b700a0b937d5c8e32a80e3a8ed4227cd020221544ee6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640087b83f9aef04d6a9b38aab1023696e3b35434a8ba4c1611b39074722e31473dd333d27000000000000000000000000826f446c587159897db0ae01192da1691f12007f0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000" + ), + "to": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": {"gasUsed": 201030, "output": HexBytes("0x")}, + "subtraces": 3, + "traceAddress": [0, 0], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0x826f446C587159897Db0aE01192dA1691f12007f", + "gas": 232202, + "value": 0, + "callType": "call", + "input": HexBytes( + "0xbd86e508736166652e657468000000000000000000000000000000000000000000000000000000000000000000000000d662e05ce522b3861b70fc376f60bf50e200abfa" + ), + "to": "0x469788fE6E9E9681C6ebF3bF78e7Fd26Fc015446", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": {"gasUsed": 24983, "output": HexBytes("0x")}, + "subtraces": 0, + "traceAddress": [0, 0, 0], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0x826f446C587159897Db0aE01192dA1691f12007f", + "gas": 204798, + "value": 0, + "callType": "call", + "input": HexBytes( + "0xbf6213e4000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000005bacaa20000000000000000000000000000000000000000000000012dfc9a7680524000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000100142b9e197e79ca94c077397bec601d9758e6ad54bfb308c529fb1ccb649664629507b80014ff96094ee587f52aed4ea98ccdce3ed4cef1139f4cbc45abf4cf3efca2d62b9a7cace73dca2d91ef5456d3fcecc2549d7cfeb33bf495a2505190b3cc6a55a90d3f5817150ad9c8d4fb3c6157c5ab99a2bd90bde5385608e212b9f0c0eb5e6c10b114900b7f894fcd9ba6126d9495341e2b09a684d822a04ba9e3bf40757bdb25323893eeac40649684dae8d5ac9be1b5688df2c8c77ffb1df3a8920c9493e26704a190c539948597a25dd0eadc3e9d3c04eef5e8bbb84dff18696e8fd0248ce2387d7c823f3b28280a73edcfd1b00d103210f5ca8f817b8c90519736c3b51ea1b6472ba406c4456d5b01c3b59343e564aae747fdd47ff6dc8ff4629d28252544a7e92977dccd4b30fb02073f088b2ad1cf0b7f5b2205842f5a78ba2580459420e11a06505b18e5ee6f8ded5bb92ee903d2eaf5d63bbecd73e213ca0870e463abf45cb18f85a835919099f45c6c56581232b3be9c06448420b35c94f4f3628f31b808b09ced67b073ab97919ba14e9389e154be4fe2b10fcdd327302fdc9a0b8cea5373715d2fce8d9ddd244f4259aafb0a21305edfdda956dd84359d69bacd790ff82f7310dd382bf3f53e885e74f5dad9b3aeb7fe96ce926ff47f8e2791b9d07b3e620189a36bf17e30c874e66b205c79492a16cd5a9c5bb65b7" + ), + "to": "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": {"gasUsed": 75351, "output": HexBytes("0x")}, + "subtraces": 1, + "traceAddress": [0, 0, 1], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6", + "gas": 188502, + "value": 0, + "callType": "staticcall", + "input": HexBytes( + "0x70a08231000000000000000000000000a0b937d5c8e32a80e3a8ed4227cd020221544ee6" + ), + "to": "0x5aFE3855358E112B5647B952709E6165e1c1eEEe", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 2886, + "output": HexBytes( + "0x000000000000000000000000000000000000000000295bd306348cee3284339a" + ), + }, + "subtraces": 0, + "traceAddress": [0, 0, 1, 0], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0x826f446C587159897Db0aE01192dA1691f12007f", + "gas": 130274, + "value": 0, + "callType": "call", + "input": HexBytes( + "0x0087b83f9aef04d6a9b38aab1023696e3b35434a8ba4c1611b39074722e31473dd333d27000000000000000000000000826f446c587159897db0ae01192da1691f12007f0000000000000000000000000000000000000000000000000de0b6b3a7640000" + ), + "to": "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": {"gasUsed": 93862, "output": HexBytes("0x")}, + "subtraces": 7, + "traceAddress": [0, 0, 2], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6", + "gas": 119997, + "value": 0, + "callType": "call", + "input": HexBytes( + "0x095ea7b30000000000000000000000008cf60b289f8d31f737049b590b5e4285ff0bd1d10000000000000000000000000000000000000000000000000de0b6b3a7640000" + ), + "to": "0x5aFE3855358E112B5647B952709E6165e1c1eEEe", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 25285, + "output": HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000001" + ), + }, + "subtraces": 0, + "traceAddress": [0, 0, 2, 0], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6", + "gas": 94401, + "value": 0, + "callType": "staticcall", + "input": HexBytes( + "0x70a08231000000000000000000000000a0b937d5c8e32a80e3a8ed4227cd020221544ee6" + ), + "to": "0x5aFE3855358E112B5647B952709E6165e1c1eEEe", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 886, + "output": HexBytes( + "0x000000000000000000000000000000000000000000295bd306348cee3284339a" + ), + }, + "subtraces": 0, + "traceAddress": [0, 0, 2, 1], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6", + "gas": 92834, + "value": 0, + "callType": "staticcall", + "input": HexBytes( + "0x70a08231000000000000000000000000826f446c587159897db0ae01192da1691f12007f" + ), + "to": "0x5aFE3855358E112B5647B952709E6165e1c1eEEe", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 2886, + "output": HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000000" + ), + }, + "subtraces": 0, + "traceAddress": [0, 0, 2, 2], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6", + "gas": 85106, + "value": 0, + "callType": "call", + "input": HexBytes( + "0x468721a70000000000000000000000005afe3855358e112b5647b952709e6165e1c1eeee000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006423b872dd000000000000000000000000a0b937d5c8e32a80e3a8ed4227cd020221544ee6000000000000000000000000826f446c587159897db0ae01192da1691f12007f0000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000" + ), + "to": "0x8CF60B289f8d31F737049B590b5E4285Ff0Bd1D1", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 41825, + "output": HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000001" + ), + }, + "subtraces": 1, + "traceAddress": [0, 0, 2, 3], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0x8CF60B289f8d31F737049B590b5E4285Ff0Bd1D1", + "gas": 79018, + "value": 0, + "callType": "delegatecall", + "input": HexBytes( + "0x468721a70000000000000000000000005afe3855358e112b5647b952709e6165e1c1eeee000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006423b872dd000000000000000000000000a0b937d5c8e32a80e3a8ed4227cd020221544ee6000000000000000000000000826f446c587159897db0ae01192da1691f12007f0000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000" + ), + "to": "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 36946, + "output": HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000001" + ), + }, + "subtraces": 1, + "traceAddress": [0, 0, 2, 3, 0], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0x8CF60B289f8d31F737049B590b5E4285Ff0Bd1D1", + "gas": 74535, + "value": 0, + "callType": "call", + "input": HexBytes( + "0x23b872dd000000000000000000000000a0b937d5c8e32a80e3a8ed4227cd020221544ee6000000000000000000000000826f446c587159897db0ae01192da1691f12007f0000000000000000000000000000000000000000000000000de0b6b3a7640000" + ), + "to": "0x5aFE3855358E112B5647B952709E6165e1c1eEEe", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 32341, + "output": HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000001" + ), + }, + "subtraces": 0, + "traceAddress": [0, 0, 2, 3, 0, 0], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6", + "gas": 43002, + "value": 0, + "callType": "call", + "input": HexBytes( + "0x095ea7b30000000000000000000000008cf60b289f8d31f737049b590b5e4285ff0bd1d10000000000000000000000000000000000000000000000000000000000000000" + ), + "to": "0x5aFE3855358E112B5647B952709E6165e1c1eEEe", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 3285, + "output": HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000001" + ), + }, + "subtraces": 0, + "traceAddress": [0, 0, 2, 4], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6", + "gas": 39062, + "value": 0, + "callType": "staticcall", + "input": HexBytes( + "0x70a08231000000000000000000000000a0b937d5c8e32a80e3a8ed4227cd020221544ee6" + ), + "to": "0x5aFE3855358E112B5647B952709E6165e1c1eEEe", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 886, + "output": HexBytes( + "0x000000000000000000000000000000000000000000295bd2f853d63a8b20339a" + ), + }, + "subtraces": 0, + "traceAddress": [0, 0, 2, 5], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + { + "action": { + "from": "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6", + "gas": 37498, + "value": 0, + "callType": "staticcall", + "input": HexBytes( + "0x70a08231000000000000000000000000826f446c587159897db0ae01192da1691f12007f" + ), + "to": "0x5aFE3855358E112B5647B952709E6165e1c1eEEe", + }, + "blockHash": "0xa1dd687d41a835a1e173b5f69f656eaad52570ca864dfaeece6bec72e17bc624", + "blockNumber": 15630274, + "result": { + "gasUsed": 886, + "output": HexBytes( + "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" + ), + }, + "subtraces": 0, + "traceAddress": [0, 0, 2, 6], + "transactionHash": "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + "transactionPosition": 80, + "type": "call", + }, + ], } diff --git a/gnosis/eth/tests/oracles/test_cowswap.py b/gnosis/eth/tests/oracles/test_cowswap.py index 2e59da05a..9782f5e2a 100644 --- a/gnosis/eth/tests/oracles/test_cowswap.py +++ b/gnosis/eth/tests/oracles/test_cowswap.py @@ -55,7 +55,9 @@ def test_get_price(self): ) self.assertAlmostEqual(price, 1.0, delta=0.5) - with mock.patch.object(Session, "get", side_effect=IOError("Connection Error")): + with mock.patch.object( + Session, "post", side_effect=IOError("Connection Error") + ): with self.assertRaisesMessage( CannotGetPriceFromOracle, f"Cannot get price from CowSwap " diff --git a/gnosis/eth/tests/oracles/test_kyber.py b/gnosis/eth/tests/oracles/test_kyber.py index 7adbc7b3f..b418a4f21 100644 --- a/gnosis/eth/tests/oracles/test_kyber.py +++ b/gnosis/eth/tests/oracles/test_kyber.py @@ -18,6 +18,8 @@ class TestKyberOracle(EthereumTestCaseMixin, TestCase): def test_kyber_oracle(self): mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(KyberOracle.is_available(ethereum_client)) kyber_oracle = KyberOracle(ethereum_client) price = kyber_oracle.get_price( gno_token_mainnet_address, weth_token_mainnet_address diff --git a/gnosis/eth/tests/oracles/test_superfluid.py b/gnosis/eth/tests/oracles/test_superfluid.py new file mode 100644 index 000000000..76235d291 --- /dev/null +++ b/gnosis/eth/tests/oracles/test_superfluid.py @@ -0,0 +1,35 @@ +from django.test import TestCase + +from eth_account import Account + +from ... import EthereumClient +from ...oracles import CannotGetPriceFromOracle, SuperfluidOracle, SushiswapOracle +from ..ethereum_test_case import EthereumTestCaseMixin +from ..test_oracles import gno_token_mainnet_address +from ..utils import just_test_if_polygon_node + + +class TestSuperfluidOracle(EthereumTestCaseMixin, TestCase): + def test_get_price(self): + polygon_node = just_test_if_polygon_node() + + self.assertFalse(SuperfluidOracle.is_available(self.ethereum_client)) + + ethereum_client_polygon = EthereumClient(polygon_node) + + self.assertTrue(SuperfluidOracle.is_available(ethereum_client_polygon)) + + sushi_oracle_polygon = SushiswapOracle(ethereum_client_polygon) + superfluid_oracle_polygon = SuperfluidOracle( + ethereum_client_polygon, sushi_oracle_polygon + ) + uscdcx_address_polygon = "0xCAa7349CEA390F89641fe306D93591f87595dc1F" + price = superfluid_oracle_polygon.get_price(uscdcx_address_polygon) + self.assertGreater(price, 0.0) + + error_message = "It is not a wrapper Super Token" + with self.assertRaisesMessage(CannotGetPriceFromOracle, error_message): + superfluid_oracle_polygon.get_price(gno_token_mainnet_address) + + with self.assertRaisesMessage(CannotGetPriceFromOracle, error_message): + superfluid_oracle_polygon.get_price(Account.create().address) diff --git a/gnosis/eth/tests/oracles/test_sushiswap.py b/gnosis/eth/tests/oracles/test_sushiswap.py index 66d98f9b6..01f2ba293 100644 --- a/gnosis/eth/tests/oracles/test_sushiswap.py +++ b/gnosis/eth/tests/oracles/test_sushiswap.py @@ -18,6 +18,8 @@ def test_get_price(self): oracles_get_decimals.cache_clear() mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(SushiswapOracle.is_available(ethereum_client)) sushiswap_oracle = SushiswapOracle(ethereum_client) price = sushiswap_oracle.get_price( diff --git a/gnosis/eth/tests/oracles/test_uniswap_v3.py b/gnosis/eth/tests/oracles/test_uniswap_v3.py index ccfb70a84..5fa51b444 100644 --- a/gnosis/eth/tests/oracles/test_uniswap_v3.py +++ b/gnosis/eth/tests/oracles/test_uniswap_v3.py @@ -59,6 +59,24 @@ def test_get_price(self): ): uniswap_v3_oracle.get_price(random_token) + # Test token with no liquidity + s_usd_token_mainnet_address = "0x57Ab1ec28D129707052df4dF418D58a2D46d5f51" + with self.assertRaisesMessage( + CannotGetPriceFromOracle, + f"Not enough liquidity on uniswap v3 for pair token_1={s_usd_token_mainnet_address} " + f"token_2=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, at least 2 units of each token are required", + ): + uniswap_v3_oracle.get_price(s_usd_token_mainnet_address) + + # Test token with no liquidity + wrapped_nxm_token_mainnet_address = "0x0d438F3b5175Bebc262bF23753C1E53d03432bDE" + with self.assertRaisesMessage( + CannotGetPriceFromOracle, + f"Not enough liquidity on uniswap v3 for pair token_1={wrapped_nxm_token_mainnet_address} " + f"token_2=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, at least 2 units of each token are required", + ): + uniswap_v3_oracle.get_price(wrapped_nxm_token_mainnet_address) + def test_get_price_contract_not_deployed(self): self.assertFalse(UniswapV3Oracle.is_available(self.ethereum_client)) with self.assertRaisesMessage( diff --git a/gnosis/eth/tests/test_ethereum_client.py b/gnosis/eth/tests/test_ethereum_client.py index c981ec49b..1388a1d0f 100644 --- a/gnosis/eth/tests/test_ethereum_client.py +++ b/gnosis/eth/tests/test_ethereum_client.py @@ -1300,7 +1300,7 @@ def test_is_contract(self): self.assertTrue(self.ethereum_client.is_contract(erc20.address)) def test_is_eip1559_supported(self): - self.assertFalse(self.ethereum_client.is_eip1559_supported()) + self.assertTrue(self.ethereum_client.is_eip1559_supported()) def test_set_eip1559_fees(self): with mock.patch.object( @@ -1378,16 +1378,24 @@ def test_trace_blocks(self): ) def test_trace_transaction(self): - tx_hash = "0x0b04589bdc11585fb98f270b1bfeff0fb3bbb3c56d35b104f62d8115d6f7c57f" # Safe 1.3.0 deployment - self.assertEqual( - self.ethereum_client.parity.trace_transaction(tx_hash), - trace_transaction_mocks[tx_hash], - ) + for tx_hash in [ + # Safe 1.3.0 deployment + "0x0b04589bdc11585fb98f270b1bfeff0fb3bbb3c56d35b104f62d8115d6f7c57f", + # Erigon v2.31.0 traceAddress issue https://github.com/ledgerwatch/erigon/issues/6375 + "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", + ]: + with self.subTest(tx_hash=tx_hash): + self.assertEqual( + self.ethereum_client.parity.trace_transaction(tx_hash), + trace_transaction_mocks[tx_hash], + ) def test_trace_transactions(self): tx_hashes = [ "0x0b04589bdc11585fb98f270b1bfeff0fb3bbb3c56d35b104f62d8115d6f7c57f", # Safe 1.3.0 deployment "0xf325b4e52d0649593e8c82f35bd389c13c13b21b61bc17de295979a21e5cfdc0", # Safe 1.1.0 setup + # Erigon v2.31.0 traceAddress issue https://github.com/ledgerwatch/erigon/issues/6375 + "0xc27273dc6e631d275baa527e1b07cd9097887317c26034bf8ea7bbe38c9353f0", ] self.assertEqual( self.ethereum_client.parity.trace_transactions(tx_hashes), diff --git a/gnosis/eth/tests/test_oracles.py b/gnosis/eth/tests/test_oracles.py index 8a035279a..6127b0ae3 100644 --- a/gnosis/eth/tests/test_oracles.py +++ b/gnosis/eth/tests/test_oracles.py @@ -40,6 +40,8 @@ class TestOracles(EthereumTestCaseMixin, TestCase): def test_uniswap_oracle(self): mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(UniswapOracle.is_available(ethereum_client)) uniswap_oracle = UniswapOracle(ethereum_client) token_address = dai_token_mainnet_address price = uniswap_oracle.get_price(token_address) @@ -100,6 +102,8 @@ def test_get_price(self): oracles_get_decimals.cache_clear() mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(UniswapV2Oracle.is_available(ethereum_client)) uniswap_v2_oracle = UniswapV2Oracle(ethereum_client) self.assertEqual(oracles_get_decimals.cache_info().currsize, 0) @@ -195,6 +199,8 @@ class TestAaveOracle(EthereumTestCaseMixin, TestCase): def test_get_token_price(self): mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(AaveOracle.is_available(ethereum_client)) uniswap_oracle = UniswapV2Oracle(ethereum_client) aave_oracle = AaveOracle(ethereum_client, uniswap_oracle) @@ -218,6 +224,8 @@ class TestCreamOracle(EthereumTestCaseMixin, TestCase): def test_get_price(self): mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(CreamOracle.is_available(ethereum_client)) sushi_oracle = SushiswapOracle(ethereum_client) cream_oracle = CreamOracle(ethereum_client, sushi_oracle) @@ -249,6 +257,8 @@ def test_get_underlying_tokens(self): mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(CurveOracle.is_available(ethereum_client)) curve_oracle = CurveOracle(ethereum_client) # Curve.fi ETH/stETH (steCRV) is working with the updated adapter @@ -321,6 +331,8 @@ def test_get_underlying_token(self): mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(PoolTogetherOracle.is_available(ethereum_client)) pool_together_oracle = PoolTogetherOracle(ethereum_client) underlying_tokens = pool_together_oracle.get_underlying_tokens( @@ -345,6 +357,8 @@ class TestYearnOracle(EthereumTestCaseMixin, TestCase): def test_get_underlying_tokens(self): mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(YearnOracle.is_available(ethereum_client)) yearn_oracle = YearnOracle(ethereum_client) yearn_token_address = "0x5533ed0a3b83F70c3c4a1f69Ef5546D3D4713E44" # Yearn Curve.fi DAI/USDC/USDT/sUSD yearn_underlying_token_address = ( @@ -400,6 +414,8 @@ class TestBalancerOracle(EthereumTestCaseMixin, TestCase): def test_get_pool_token_price(self): mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(BalancerOracle.is_available(ethereum_client)) uniswap_oracle = UniswapV2Oracle(ethereum_client) balancer_oracle = BalancerOracle(ethereum_client, uniswap_oracle) balancer_token_address = ( @@ -424,6 +440,8 @@ class TestMooniswapOracle(EthereumTestCaseMixin, TestCase): def test_get_pool_token_price(self): mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(MooniswapOracle.is_available(ethereum_client)) uniswap_oracle = UniswapV2Oracle(ethereum_client) mooniswap_oracle = MooniswapOracle(ethereum_client, uniswap_oracle) mooniswap_pool_address = "0x6a11F3E5a01D129e566d783A7b6E8862bFD66CcA" # 1inch Liquidity Pool (ETH-WBTC) @@ -455,6 +473,8 @@ class TestEnzymeOracle(EthereumTestCaseMixin, TestCase): def test_get_underlying_tokens(self): mainnet_node = just_test_if_mainnet_node() ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(EnzymeOracle.is_available(ethereum_client)) enzyme_oracle = EnzymeOracle(ethereum_client) mln_vault_token_address = "0x45c45799Bcf6C7Eb2Df0DA1240BE04cE1D18CC69" mln_vault_underlying_token = "0xec67005c4E498Ec7f55E092bd1d35cbC47C91892" diff --git a/gnosis/eth/tests/utils.py b/gnosis/eth/tests/utils.py index a77792b25..6cb0547e3 100644 --- a/gnosis/eth/tests/utils.py +++ b/gnosis/eth/tests/utils.py @@ -21,7 +21,7 @@ def just_test_if_mainnet_node() -> str: ) else: try: - if not requests.post( + response = requests.post( mainnet_node_url, timeout=5, json={ @@ -30,14 +30,45 @@ def just_test_if_mainnet_node() -> str: "params": [], "id": 1, }, + ) + if not response.ok: + pytest.fail( + f"Problem connecting to mainnet node {response.status_code} - {response.content}" + ) + except IOError: + pytest.fail("Problem connecting to the mainnet node") + just_test_if_mainnet_node.checked = True + return mainnet_node_url + + +def just_test_if_polygon_node() -> str: + polygon_node_url = os.environ.get("ETHEREUM_POLYGON_NODE") + if hasattr(just_test_if_polygon_node, "checked"): # Just check node first time + return polygon_node_url + + if not polygon_node_url: + pytest.skip( + "Polygon node not defined, cannot test oracles", allow_module_level=True + ) + else: + try: + if not requests.post( + polygon_node_url, + timeout=5, + json={ + "jsonrpc": "2.0", + "method": "eth_blockNumber", + "params": [], + "id": 1, + }, ).ok: - pytest.skip("Cannot connect to mainnet node", allow_module_level=True) + pytest.skip("Cannot connect to poylgon node", allow_module_level=True) except IOError: pytest.skip( - "Problem connecting to the mainnet node", allow_module_level=True + "Problem connecting to the polygon node", allow_module_level=True ) - just_test_if_mainnet_node.checked = True - return mainnet_node_url + just_test_if_polygon_node.checked = True + return polygon_node_url def send_tx(w3: Web3, tx: TxParams, account: LocalAccount) -> bytes: diff --git a/gnosis/eth/typing.py b/gnosis/eth/typing.py index d97639f2b..efeb57971 100644 --- a/gnosis/eth/typing.py +++ b/gnosis/eth/typing.py @@ -1,14 +1,8 @@ -from typing import Optional, Union +from typing import Optional, TypedDict, Union from eth_typing import Hash32, HexStr from hexbytes import HexBytes -try: - from typing import TypedDict # pylint: disable=no-name-in-module -except ImportError: - from typing_extensions import TypedDict - - EthereumHash = Union[Hash32, HexBytes, HexStr] EthereumData = Union[bytes, HexStr] diff --git a/gnosis/protocol/gnosis_protocol_api.py b/gnosis/protocol/gnosis_protocol_api.py index 5d906f07e..0d438cef1 100644 --- a/gnosis/protocol/gnosis_protocol_api.py +++ b/gnosis/protocol/gnosis_protocol_api.py @@ -1,23 +1,18 @@ -from typing import Any, Dict, List, Optional, Union, cast +from functools import cached_property +from typing import Any, Dict, List, Optional, TypedDict, Union, cast import requests -from eip712_structs import make_domain from eth_account import Account from eth_account.messages import encode_defunct from eth_typing import AnyAddress, ChecksumAddress, HexStr from hexbytes import HexBytes -from web3 import Web3 from gnosis.eth import EthereumNetwork, EthereumNetworkNotSupported -from gnosis.util import cached_property +from gnosis.eth.eip712 import eip712_encode_hash +from ..eth.constants import NULL_ADDRESS from .order import Order, OrderKind -try: - from typing import TypedDict # pylint: disable=no-name-in-module -except ImportError: - from typing_extensions import TypedDict - class TradeResponse(TypedDict): blockNumber: int @@ -33,8 +28,8 @@ class TradeResponse(TypedDict): class AmountResponse(TypedDict): - amount: str - token: AnyAddress + sellAmount: int + buyAmount: int class ErrorResponse(TypedDict): @@ -47,26 +42,28 @@ class GnosisProtocolAPI: Client for GnosisProtocol API. More info: https://docs.cowswap.exchange/ """ - settlement_contract_addresses = { + SETTLEMENT_CONTRACT_ADDRESSES = { EthereumNetwork.MAINNET: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", - EthereumNetwork.RINKEBY: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + EthereumNetwork.GOERLI: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", EthereumNetwork.XDAI: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", } - api_base_urls = { + API_BASE_URLS = { EthereumNetwork.MAINNET: "https://api.cow.fi/mainnet/api/v1/", - EthereumNetwork.RINKEBY: "https://api.cow.fi/rinkeby/api/v1/", + EthereumNetwork.GOERLI: "https://api.cow.fi/goerli/api/v1/", EthereumNetwork.XDAI: "https://api.cow.fi/xdai/api/v1/", } def __init__(self, ethereum_network: EthereumNetwork): self.network = ethereum_network - if self.network not in self.api_base_urls: + if self.network not in self.API_BASE_URLS: raise EthereumNetworkNotSupported( f"{self.network.name} network not supported by Gnosis Protocol" ) - self.domain_separator = self.build_domain_separator(self.network) - self.base_url = self.api_base_urls[self.network] + self.settlement_contract_address = self.SETTLEMENT_CONTRACT_ADDRESSES[ + self.network + ] + self.base_url = self.API_BASE_URLS[self.network] self.http_session = requests.Session() @cached_property @@ -81,65 +78,84 @@ def weth_address(self) -> ChecksumAddress: else: # XDAI return ChecksumAddress("0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1") - @classmethod - def build_domain_separator(cls, ethereum_network: EthereumNetwork): - return make_domain( - name="Gnosis Protocol", - version="v2", - chainId=str(ethereum_network.value), - verifyingContract=cls.settlement_contract_addresses[ethereum_network], - ) - - def get_fee(self, order: Order) -> int: - if order["kind"] == "sell": - amount = order["sellAmount"] + def get_quote( + self, order: Order, from_address: ChecksumAddress + ) -> Union[Dict[str, Any], ErrorResponse]: + url = self.base_url + "quote" + data_json = { + "sellToken": order.sellToken.lower(), + "buyToken": order.buyToken.lower(), + "sellAmountBeforeFee": str(order.sellAmount), + # "validTo": order.validTo, + "appData": HexBytes(order.appData).hex() + if isinstance(order.appData, bytes) + else order.appData, + "feeAmount": str(order.feeAmount), + "kind": order.kind, + "partiallyFillable": order.partiallyFillable, + "signingScheme": "ethsign", + "from": from_address, + "priceQuality": "fast", + } + r = self.http_session.post(url, json=data_json) + if r.ok: + return r.json() else: - amount = order["buyAmount"] - url = ( - self.base_url - + f'fee/?sellToken={order["sellToken"]}&buyToken={order["buyToken"]}' - f'&amount={amount}&kind={order["kind"]}' - ) - result = self.http_session.get(url).json() - if "amount" in result: - return int(result["amount"]) + return ErrorResponse(r.json()) + + def get_fee( + self, order: Order, from_address: ChecksumAddress + ) -> Union[int, ErrorResponse]: + quote = self.get_quote(order, from_address) + + if "quote" in quote: + return int(quote["quote"]["feeAmount"]) else: - return 0 + return quote def place_order( self, order: Order, private_key: HexStr ) -> Union[HexStr, ErrorResponse]: """ - Place order. If `feeAmount=0` in Order it will be calculated calling `get_fee(order)` + Place order. If `feeAmount=0` in Order it will be calculated calling `get_fee(order, from_address)` :return: UUID for the order as an hex hash """ assert ( - order["buyAmount"] and order["sellAmount"] + order.buyAmount and order.sellAmount ), "Order buyAmount and sellAmount cannot be empty" url = self.base_url + "orders/" - order["feeAmount"] = order["feeAmount"] or self.get_fee(order) - signable_bytes = order.signable_bytes(domain=self.domain_separator) - signable_hash = Web3.keccak(signable_bytes) + from_address = Account.from_key(private_key).address + if not order.feeAmount: + fee_amount = self.get_fee(order, from_address) + if "errorType" in fee_amount: # ErrorResponse + return fee_amount + order.feeAmount = fee_amount + + signable_hash = eip712_encode_hash( + order.get_eip712_structured_data( + self.network.value, self.settlement_contract_address + ) + ) message = encode_defunct(primitive=signable_hash) signed_message = Account.from_key(private_key).sign_message(message) data_json = { - "sellToken": order["sellToken"].lower(), - "buyToken": order["buyToken"].lower(), - "sellAmount": str(order["sellAmount"]), - "buyAmount": str(order["buyAmount"]), - "validTo": order["validTo"], - "appData": HexBytes(order["appData"]).hex() - if isinstance(order["appData"], bytes) - else order["appData"], - "feeAmount": str(order["feeAmount"]), - "kind": order["kind"], - "partiallyFillable": order["partiallyFillable"], + "sellToken": order.sellToken.lower(), + "buyToken": order.buyToken.lower(), + "sellAmount": str(order.sellAmount), + "buyAmount": str(order.buyAmount), + "validTo": order.validTo, + "appData": HexBytes(order.appData).hex() + if isinstance(order.appData, bytes) + else order.appData, + "feeAmount": str(order.feeAmount), + "kind": order.kind, + "partiallyFillable": order.partiallyFillable, "signature": signed_message.signature.hex(), "signingScheme": "ethsign", - "from": Account.from_key(private_key).address, + "from": from_address, } r = self.http_session.post(url, json=data_json) if r.ok: @@ -190,14 +206,36 @@ def get_estimated_amount( base_token: ChecksumAddress, quote_token: ChecksumAddress, kind: OrderKind, - amount: int, + amount_wei: int, ) -> Union[AmountResponse, ErrorResponse]: """ - The estimated amount in quote token for either buying or selling amount of baseToken. + + :param base_token: + :param quote_token: + :param kind: + :param amount_wei: + :return: Both `sellAmount` and `buyAmount` as they can be adjusted by CowSwap API """ - url = self.base_url + f"markets/{base_token}-{quote_token}/{kind.name}/{amount}" - r = self.http_session.get(url) - if r.ok: - return AmountResponse(r.json()) + order = Order( + sellToken=base_token, + buyToken=quote_token, + receiver=NULL_ADDRESS, + sellAmount=amount_wei * 10 if kind == OrderKind.SELL else 0, + buyAmount=amount_wei * 10 if kind == OrderKind.BUY else 0, + validTo=0, # Valid for 1 hour + appData="0x0000000000000000000000000000000000000000000000000000000000000000", + feeAmount=0, + kind=kind.name.lower(), # `sell` or `buy` + partiallyFillable=False, + sellTokenBalance="erc20", # `erc20`, `external` or `internal` + buyTokenBalance="erc20", # `erc20` or `internal` + ) + + quote = self.get_quote(order, NULL_ADDRESS) + if "quote" in quote: + return { + "buyAmount": int(quote["quote"]["buyAmount"]), + "sellAmount": int(quote["quote"]["sellAmount"]), + } else: - return ErrorResponse(r.json()) + return quote diff --git a/gnosis/protocol/order.py b/gnosis/protocol/order.py index 7c5af533c..446a329dc 100644 --- a/gnosis/protocol/order.py +++ b/gnosis/protocol/order.py @@ -1,21 +1,79 @@ +from dataclasses import dataclass from enum import Enum +from typing import Any, Dict, Literal -from eip712_structs import Address, Boolean, Bytes, EIP712Struct, String, Uint - - -class Order(EIP712Struct): - sellToken = Address() - buyToken = Address() - receiver = Address() - sellAmount = Uint(256) - buyAmount = Uint(256) - validTo = Uint(32) - appData = Bytes(32) - feeAmount = Uint(256) - kind = String() # `sell` or `buy` - partiallyFillable = Boolean() - sellTokenBalance = String() # `erc20`, `external` or `internal` - buyTokenBalance = String() # `erc20` or `internal` +from eth_typing import ChecksumAddress, Hash32 + + +@dataclass +class Order: + sellToken: ChecksumAddress + buyToken: ChecksumAddress + receiver: ChecksumAddress + sellAmount: int + buyAmount: int + validTo: int + appData: Hash32 + feeAmount: int + kind: Literal["sell", "buy"] + partiallyFillable: bool + sellTokenBalance: Literal["erc20", "external", "internal"] + buyTokenBalance: Literal["erc20", "internal"] + + def is_sell_order(self) -> bool: + return self.kind == "sell" + + def get_eip712_structured_data( + self, chain_id: int, verifying_contract: ChecksumAddress + ) -> Dict[str, Any]: + types = { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "Order": [ + {"name": "sellToken", "type": "address"}, + {"name": "buyToken", "type": "address"}, + {"name": "receiver", "type": "address"}, + {"name": "sellAmount", "type": "uint256"}, + {"name": "buyAmount", "type": "uint256"}, + {"name": "validTo", "type": "uint32"}, + {"name": "appData", "type": "bytes32"}, + {"name": "feeAmount", "type": "uint256"}, + {"name": "kind", "type": "string"}, + {"name": "partiallyFillable", "type": "bool"}, + {"name": "sellTokenBalance", "type": "string"}, + {"name": "buyTokenBalance", "type": "string"}, + ], + } + message = { + "sellToken": self.sellToken, + "buyToken": self.buyToken, + "receiver": self.receiver, + "sellAmount": self.sellAmount, + "buyAmount": self.buyAmount, + "validTo": self.validTo, + "appData": self.appData, + "feeAmount": self.feeAmount, + "kind": self.kind, + "partiallyFillable": self.partiallyFillable, + "sellTokenBalance": self.sellTokenBalance, + "buyTokenBalance": self.buyTokenBalance, + } + + return { + "types": types, + "primaryType": "Order", + "domain": { + "name": "Gnosis Protocol", + "version": "v2", + "chainId": chain_id, + "verifyingContract": verifying_contract, + }, + "message": message, + } class OrderKind(Enum): diff --git a/gnosis/protocol/tests/test_gnosis_protocol_api.py b/gnosis/protocol/tests/test_gnosis_protocol_api.py index c7180f19b..c15690b25 100644 --- a/gnosis/protocol/tests/test_gnosis_protocol_api.py +++ b/gnosis/protocol/tests/test_gnosis_protocol_api.py @@ -2,6 +2,7 @@ from django.test import TestCase +import pytest from eth_account import Account from web3 import Web3 @@ -14,25 +15,30 @@ class TestGnosisProtocolAPI(TestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.gnosis_protocol_api = GnosisProtocolAPI(EthereumNetwork.RINKEBY) - cls.gno_token_address = "0x6810e776880C02933D47DB1b9fc05908e5386b96" - cls.weth_token_address = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - cls.rinkeby_dai_address = "0x5592EC0cfb4dbc12D3aB100b257153436a1f0FEa" + cls.mainnet_gnosis_protocol_api = GnosisProtocolAPI(EthereumNetwork.MAINNET) + cls.goerli_gnosis_protocol_api = GnosisProtocolAPI(EthereumNetwork.GOERLI) + cls.mainnet_gno_token_address = "0x6810e776880C02933D47DB1b9fc05908e5386b96" + cls.goerli_cow_token_address = "0x3430d04E42a722c5Ae52C5Bffbf1F230C2677600" def test_api_is_available(self): random_owner = Account.create().address for ethereum_network in ( EthereumNetwork.MAINNET, - EthereumNetwork.RINKEBY, + EthereumNetwork.GOERLI, EthereumNetwork.XDAI, ): with self.subTest(ethereum_network=ethereum_network): - self.assertEqual(self.gnosis_protocol_api.get_orders(random_owner), []) + self.assertEqual( + self.goerli_gnosis_protocol_api.get_orders(random_owner), [] + ) def test_get_estimated_amount(self): gnosis_protocol_api = GnosisProtocolAPI(EthereumNetwork.MAINNET) response = gnosis_protocol_api.get_estimated_amount( - self.gno_token_address, self.gno_token_address, OrderKind.SELL, 1 + self.mainnet_gno_token_address, + self.mainnet_gno_token_address, + OrderKind.SELL, + 1, ) self.assertDictEqual( response, @@ -44,7 +50,7 @@ def test_get_estimated_amount(self): response = gnosis_protocol_api.get_estimated_amount( "0x6820e776880c02933d47db1b9fc05908e5386b96", - self.gno_token_address, + self.mainnet_gno_token_address, OrderKind.SELL, 1, ) @@ -52,18 +58,21 @@ def test_get_estimated_amount(self): self.assertIn("description", response) response = gnosis_protocol_api.get_estimated_amount( - self.gno_token_address, self.weth_token_address, OrderKind.SELL, int(1e18) + self.mainnet_gno_token_address, + self.mainnet_gnosis_protocol_api.weth_address, + OrderKind.SELL, + int(1e18), ) - amount = int(response["amount"]) / 1e18 + amount = float(response["buyAmount"]) / response["sellAmount"] self.assertGreater(amount, 0) self.assertLess(amount, 1) def test_get_fee(self): order = Order( - sellToken=self.gno_token_address, - buyToken=self.gno_token_address, + sellToken=self.mainnet_gno_token_address, + buyToken=self.mainnet_gno_token_address, receiver=NULL_ADDRESS, - sellAmount=1, + sellAmount=int(1e18), buyAmount=1, validTo=int(time()) + 3600, appData=Web3.keccak(text="hola"), @@ -73,34 +82,43 @@ def test_get_fee(self): sellTokenBalance="erc20", buyTokenBalance="erc20", ) - self.assertGreaterEqual(self.gnosis_protocol_api.get_fee(order), 0) + from_address = Account.create().address + self.assertEqual( + self.mainnet_gnosis_protocol_api.get_fee(order, from_address), + { + "errorType": "SameBuyAndSellToken", + "description": "Buy token is the same as the sell token.", + }, + ) + order.buyToken = self.mainnet_gnosis_protocol_api.weth_address + self.assertGreaterEqual( + self.mainnet_gnosis_protocol_api.get_fee(order, from_address), 0 + ) def test_get_trades(self): + mainnet_order_ui = "0x65F1206182C77A040ED41D507B59C622FA94AB5E71CCA567202CFF3909F3D5C4DBE338E45276630FD8237149DD47EE027AF26F9C619723D0" self.assertEqual( - self.gnosis_protocol_api.get_trades( - "0x9c79b5883b7f2bacbedef554a835fb07c21f4b1b046edf510554a6ba0444d2665ac255889882acd3da2aa939679e3f3d4ce" - "a221e72eb7b80" - ), + self.mainnet_gnosis_protocol_api.get_trades(order_ui=mainnet_order_ui), [ { - "blockNumber": 9269212, + "blockNumber": 13643462, "logIndex": 0, - "orderUid": "0x9c79b5883b7f2bacbedef554a835fb07c21f4b1b046edf510554a6ba0444d2665ac255889882acd3da2aa939679e3f3d4cea221e72eb7b80", - "buyAmount": "480792", - "sellAmount": "400000000200001", - "sellAmountBeforeFees": "1", - "owner": "0x5ac255889882acd3da2aa939679e3f3d4cea221e", - "buyToken": "0x5592ec0cfb4dbc12d3ab100b257153436a1f0fea", - "sellToken": "0xc778417e063141139fce010982780140aa0cd5ab", - "txHash": "0x4c888ddeac38b195c9ff7220b61df836a49f8fe2fd9a448da2caf56308db1c61", + "orderUid": "0x65f1206182c77a040ed41d507b59c622fa94ab5e71cca567202cff3909f3d5c4dbe338e45276630fd8237149dd47ee027af26f9c619723d0", + "buyAmount": "28361861093850079821", + "sellAmount": "113521821882", + "sellAmountBeforeFees": "113465370931", + "owner": "0xdbe338e45276630fd8237149dd47ee027af26f9c", + "buyToken": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "sellToken": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "txHash": "0x691d1a8ba39c036e841b6e2ed970f9068ac4a27b61955afb852f11019f2ff4d8", } ], ) def test_place_order(self): order = Order( - sellToken=self.gno_token_address, - buyToken=self.gno_token_address, + sellToken=self.mainnet_gno_token_address, + buyToken=self.mainnet_gno_token_address, receiver=NULL_ADDRESS, sellAmount=1, buyAmount=1, @@ -112,9 +130,11 @@ def test_place_order(self): sellTokenBalance="erc20", buyTokenBalance="erc20", ) - result = self.gnosis_protocol_api.place_order(order, Account().create().key) + result = self.goerli_gnosis_protocol_api.place_order( + order, Account().create().key + ) self.assertEqual( - order["feeAmount"], 0 + order.feeAmount, 0 ) # Cannot estimate, as buy token is the same as the sell token self.assertEqual( result, @@ -124,12 +144,15 @@ def test_place_order(self): }, ) - order["sellToken"] = self.gnosis_protocol_api.weth_address - order["buyToken"] = self.rinkeby_dai_address - self.assertEqual( - self.gnosis_protocol_api.place_order(order, Account().create().key), - { - "description": "order owner must have funds worth at least x in his account", - "errorType": "InsufficientBalance", - }, + order.sellToken = self.goerli_gnosis_protocol_api.weth_address + order.buyToken = self.goerli_cow_token_address + order_id = self.goerli_gnosis_protocol_api.place_order( + order, Account().create().key ) + + if type(order_id) is dict: + if order_id["errorType"] == "NoLiquidity": + pytest.xfail("NoLiquidity Error") + + self.assertEqual(order_id[:2], "0x") + self.assertEqual(len(order_id), 114) diff --git a/gnosis/safe/api/relay_service_api.py b/gnosis/safe/api/relay_service_api.py index 6a7329fd4..d5e213a59 100644 --- a/gnosis/safe/api/relay_service_api.py +++ b/gnosis/safe/api/relay_service_api.py @@ -1,3 +1,4 @@ +from typing import TypedDict from urllib.parse import urljoin import requests @@ -9,11 +10,6 @@ from ..signatures import signature_split from .base_api import SafeAPIException, SafeBaseAPI -try: - from typing import TypedDict -except ImportError: - from typing_extensions import TypedDict - class RelayEstimation(TypedDict): safeTxGas: int diff --git a/gnosis/safe/api/transaction_service_api.py b/gnosis/safe/api/transaction_service_api.py index c80b13cdb..957c7a832 100644 --- a/gnosis/safe/api/transaction_service_api.py +++ b/gnosis/safe/api/transaction_service_api.py @@ -17,18 +17,17 @@ class TransactionServiceApi(SafeBaseAPI): URL_BY_NETWORK = { - EthereumNetwork.ARBITRUM: "https://safe-transaction.arbitrum.gnosis.io", - EthereumNetwork.AURORA: "https://safe-transaction.aurora.gnosis.io", - EthereumNetwork.AVALANCHE: "https://safe-transaction.avalanche.gnosis.io", - EthereumNetwork.BINANCE: "https://safe-transaction.bsc.gnosis.io", - EthereumNetwork.ENERGY_WEB_CHAIN: "https://safe-transaction.ewc.gnosis.io", - EthereumNetwork.GOERLI: "https://safe-transaction.goerli.gnosis.io", - EthereumNetwork.MAINNET: "https://safe-transaction.mainnet.gnosis.io", - EthereumNetwork.MATIC: "https://safe-transaction.polygon.gnosis.io", - EthereumNetwork.OPTIMISTIC: "https://safe-transaction.optimism.gnosis.io", - EthereumNetwork.RINKEBY: "https://safe-transaction.rinkeby.gnosis.io", - EthereumNetwork.VOLTA: "https://safe-transaction.volta.gnosis.io", - EthereumNetwork.XDAI: "https://safe-transaction.xdai.gnosis.io", + EthereumNetwork.ARBITRUM: "https://safe-transaction-arbitrum.safe.global", + EthereumNetwork.AURORA: "https://safe-transaction-aurora.safe.global", + EthereumNetwork.AVALANCHE: "https://safe-transaction-avalanche.safe.global", + EthereumNetwork.BINANCE: "https://safe-transaction-bsc.safe.global", + EthereumNetwork.ENERGY_WEB_CHAIN: "https://safe-transaction-ewc.safe.global", + EthereumNetwork.GOERLI: "https://safe-transaction-goerli.safe.global", + EthereumNetwork.MAINNET: "https://safe-transaction-mainnet.safe.global", + EthereumNetwork.MATIC: "https://safe-transaction-polygon.safe.global", + EthereumNetwork.OPTIMISTIC: "https://safe-transaction-optimism.safe.global", + EthereumNetwork.VOLTA: "https://safe-transaction-volta.safe.global", + EthereumNetwork.XDAI: "https://safe-transaction-gnosis-chain.safe.global", } @classmethod diff --git a/gnosis/safe/proxy_factory.py b/gnosis/safe/proxy_factory.py index 0d8504e61..dc643d30c 100644 --- a/gnosis/safe/proxy_factory.py +++ b/gnosis/safe/proxy_factory.py @@ -17,14 +17,7 @@ get_proxy_factory_V1_1_1_contract, ) from gnosis.eth.utils import compare_byte_code, fast_is_checksum_address - -try: - from functools import cache -except ImportError: - from functools import lru_cache - - cache = lru_cache(maxsize=None) - +from gnosis.util import cache logger = getLogger(__name__) diff --git a/gnosis/safe/safe.py b/gnosis/safe/safe.py index bbc2e0a6e..2ef0aa966 100644 --- a/gnosis/safe/safe.py +++ b/gnosis/safe/safe.py @@ -1,12 +1,16 @@ import dataclasses import math from enum import Enum +from functools import cached_property from logging import getLogger from typing import Callable, List, NamedTuple, Optional, Union +from eth_abi import encode_abi +from eth_abi.exceptions import DecodingError +from eth_abi.packed import encode_abi_packed from eth_account import Account from eth_account.signers.local import LocalAccount -from eth_typing import ChecksumAddress +from eth_typing import ChecksumAddress, Hash32 from hexbytes import HexBytes from web3 import Web3 from web3.contract import Contract @@ -27,10 +31,10 @@ from gnosis.eth.utils import ( fast_bytes_to_checksum_address, fast_is_checksum_address, + fast_keccak, get_eth_address_with_key, ) from gnosis.safe.proxy_factory import ProxyFactory -from gnosis.util import cached_property from ..eth.typing import EthereumData from .exceptions import ( @@ -76,13 +80,20 @@ class Safe: Class to manage a Gnosis Safe """ + # keccak256("fallback_manager.handler.address") FALLBACK_HANDLER_STORAGE_SLOT = ( 0x6C9A6C4A39284E37ED1CF53D337577D14212A4870FB976A4366C693B939918D5 ) + # keccak256("guard_manager.guard.address") GUARD_STORAGE_SLOT = ( 0x4A204F620C8C5CCDCA3FD54D003BADD85BA500436A431F0CBDA4F558C93C34C8 ) + # keccak256("SafeMessage(bytes message)"); + SAFE_MESSAGE_TYPEHASH = bytes.fromhex( + "60b3cbf8b4a223d68d641b3b6ddf9a298e7f33710cf3d3a9d1146b5a6150fbca" + ) + def __init__(self, address: ChecksumAddress, ethereum_client: EthereumClient): """ :param address: Safe address @@ -97,6 +108,26 @@ def __init__(self, address: ChecksumAddress, ethereum_client: EthereumClient): def __str__(self): return f"Safe={self.address}" + @cached_property + def contract(self) -> Contract: + v_1_3_0_contract = get_safe_V1_3_0_contract(self.w3, address=self.address) + version = v_1_3_0_contract.functions.VERSION().call() + if version == "1.3.0": + return v_1_3_0_contract + else: + return get_safe_V1_1_1_contract(self.w3, address=self.address) + + @cached_property + def domain_separator(self) -> Optional[bytes]: + """ + :return: EIP721 DomainSeparator for the Safe. Returns `None` if not supported (for Safes < 1.0.0) + """ + try: + return self.retrieve_domain_separator() + except (ValueError, BadFunctionCallOutput, DecodingError): + logger.warning("Safe %s does not support domainSeparator", self.address) + return None + @staticmethod def create( ethereum_client: EthereumClient, @@ -495,15 +526,6 @@ def build_safe_create2_tx( return safe_creation_tx - @cached_property - def contract(self) -> Contract: - v_1_3_0_contract = get_safe_V1_3_0_contract(self.w3, address=self.address) - version = v_1_3_0_contract.functions.VERSION().call() - if version == "1.3.0": - return v_1_3_0_contract - else: - return get_safe_V1_1_1_contract(self.w3, address=self.address) - def check_funds_for_tx_gas( self, safe_tx_gas: int, base_gas: int, gas_price: int, gas_token: str ) -> bool: @@ -790,7 +812,7 @@ def estimate_tx_gas(self, to: str, value: int, data: bytes, operation: int) -> i # So gas needed by caller will be around 35k OLD_CALL_GAS = 35000 # Web3 `estimate_gas` estimates less gas - WEB3_ESTIMATION_OFFSET = 20000 + WEB3_ESTIMATION_OFFSET = 23000 ADDITIONAL_GAS = PROXY_GAS + OLD_CALL_GAS try: @@ -805,7 +827,7 @@ def estimate_tx_gas(self, to: str, value: int, data: bytes, operation: int) -> i + WEB3_ESTIMATION_OFFSET ) - def estimate_tx_operational_gas(self, data_bytes_length: int): + def estimate_tx_operational_gas(self, data_bytes_length: int) -> int: """ DEPRECATED. `estimate_tx_base_gas` already includes this. Estimates the gas for the verification of the signatures and other safe related tasks @@ -822,6 +844,35 @@ def estimate_tx_operational_gas(self, data_bytes_length: int): threshold = self.retrieve_threshold() return 15000 + data_bytes_length // 32 * 100 + 5000 * threshold + def get_message_hash(self, message: Union[str, Hash32]) -> Hash32: + """ + Return hash of a message that can be signed by owners. + + :param message: Message that should be hashed + :return: Message hash + """ + + if isinstance(message, str): + message = message.encode() + message_hash = fast_keccak(message) + + safe_message_hash = Web3.keccak( + encode_abi( + ["bytes32", "bytes32"], [self.SAFE_MESSAGE_TYPEHASH, message_hash] + ) + ) + return Web3.keccak( + encode_abi_packed( + ["bytes1", "bytes1", "bytes32", "bytes32"], + [ + bytes.fromhex("19"), + bytes.fromhex("01"), + self.domain_separator, + safe_message_hash, + ], + ) + ) + def retrieve_all_info( self, block_identifier: Optional[BlockIdentifier] = "latest" ) -> SafeInfo: @@ -873,6 +924,13 @@ def retrieve_all_info( except (ValueError, BadFunctionCallOutput) as e: raise CannotRetrieveSafeInfoException(self.address) from e + def retrieve_domain_separator( + self, block_identifier: Optional[BlockIdentifier] = "latest" + ) -> str: + return self.contract.functions.domainSeparator().call( + block_identifier=block_identifier + ) + def retrieve_code(self) -> HexBytes: return self.w3.eth.get_code(self.address) diff --git a/gnosis/safe/safe_tx.py b/gnosis/safe/safe_tx.py index dd5e4a51f..a26063682 100644 --- a/gnosis/safe/safe_tx.py +++ b/gnosis/safe/safe_tx.py @@ -1,7 +1,6 @@ +from functools import cached_property from typing import Any, Dict, List, NoReturn, Optional, Tuple, Type -from eip712_structs import Address, Bytes, EIP712Struct, Uint, make_domain -from eip712_structs.struct import StructTuple from eth_account import Account from hexbytes import HexBytes from packaging.version import Version @@ -11,10 +10,9 @@ from gnosis.eth import EthereumClient from gnosis.eth.constants import NULL_ADDRESS from gnosis.eth.contracts import get_safe_contract -from gnosis.util import cached_property +from gnosis.eth.eip712 import eip712_encode_hash +from gnosis.eth.ethereum_client import TxSpeed -from ..eth.ethereum_client import TxSpeed -from ..eth.utils import fast_keccak from .exceptions import ( CouldNotFinishInitialization, CouldNotPayGasWithEther, @@ -39,36 +37,6 @@ from .signatures import signature_to_bytes -class EIP712SafeTx(EIP712Struct): - to = Address() - value = Uint(256) - data = Bytes() - operation = Uint(8) - safeTxGas = Uint(256) - baseGas = Uint(256) # `dataGas` was renamed to `baseGas` in 1.0.0 - gasPrice = Uint(256) - gasToken = Address() - refundReceiver = Address() - nonce = Uint(256) - - -class EIP712LegacySafeTx(EIP712Struct): - to = Address() - value = Uint(256) - data = Bytes() - operation = Uint(8) - safeTxGas = Uint(256) - dataGas = Uint(256) - gasPrice = Uint(256) - gasToken = Address() - refundReceiver = Address() - nonce = Uint(256) - - -EIP712SafeTx.type_name = "SafeTx" -EIP712LegacySafeTx.type_name = "SafeTx" - - class SafeTx: def __init__( self, @@ -165,39 +133,58 @@ def safe_version(self) -> str: return self.contract.functions.VERSION().call() @property - def _eip712_payload(self) -> StructTuple: - data = self.data.hex() if self.data else "" + def eip712_structured_data(self) -> Dict[str, Any]: safe_version = Version(self.safe_version) - cls = EIP712SafeTx if safe_version >= Version("1.0.0") else EIP712LegacySafeTx - message = cls( - to=self.to, - value=self.value, - data=data, - operation=self.operation, - safeTxGas=self.safe_tx_gas, - baseGas=self.base_gas, - dataGas=self.base_gas, - gasPrice=self.gas_price, - gasToken=self.gas_token, - refundReceiver=self.refund_receiver, - nonce=self.safe_nonce, - ) - domain = make_domain( - verifyingContract=self.safe_address, - chainId=self.chain_id if safe_version >= Version("1.3.0") else None, - ) - return StructTuple(message, domain) - @property - def eip712_structured_data(self) -> Dict: - message, domain = self._eip712_payload - return message.to_message(domain) + # Safes >= 1.0.0 Renamed `baseGas` to `dataGas` + base_gas_key = "baseGas" if safe_version >= Version("1.0.0") else "dataGas" + + types = { + "EIP712Domain": [{"name": "verifyingContract", "type": "address"}], + "SafeTx": [ + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "data", "type": "bytes"}, + {"name": "operation", "type": "uint8"}, + {"name": "safeTxGas", "type": "uint256"}, + {"name": base_gas_key, "type": "uint256"}, + {"name": "gasPrice", "type": "uint256"}, + {"name": "gasToken", "type": "address"}, + {"name": "refundReceiver", "type": "address"}, + {"name": "nonce", "type": "uint256"}, + ], + } + message = { + "to": self.to, + "value": self.value, + "data": self.data, + "operation": self.operation, + "safeTxGas": self.safe_tx_gas, + base_gas_key: self.base_gas, + "dataGas": self.base_gas, + "gasPrice": self.gas_price, + "gasToken": self.gas_token, + "refundReceiver": self.refund_receiver, + "nonce": self.safe_nonce, + } + + payload = { + "types": types, + "primaryType": "SafeTx", + "domain": {"verifyingContract": self.safe_address}, + "message": message, + } + + # Enable chainId from v1.3.0 onwards + if safe_version >= Version("1.3.0"): + payload["domain"]["chainId"] = self.chain_id + types["EIP712Domain"].insert(0, {"name": "chainId", "type": "uint256"}) + + return payload @property def safe_tx_hash(self) -> HexBytes: - message, domain = self._eip712_payload - signable_bytes = message.signable_bytes(domain) - return HexBytes(fast_keccak(signable_bytes)) + return HexBytes(eip712_encode_hash(self.eip712_structured_data)) @property def signers(self) -> List[str]: diff --git a/gnosis/safe/tests/api/test_transaction_service_api.py b/gnosis/safe/tests/api/test_transaction_service_api.py index 1f05ac36f..cda0006af 100644 --- a/gnosis/safe/tests/api/test_transaction_service_api.py +++ b/gnosis/safe/tests/api/test_transaction_service_api.py @@ -11,12 +11,12 @@ class TestTransactionServiceAPI(EthereumTestCaseMixin, TestCase): def setUp(self) -> None: self.transaction_service_api = TransactionServiceApi( - EthereumNetwork.RINKEBY, ethereum_client=self.ethereum_client + EthereumNetwork.GOERLI, ethereum_client=self.ethereum_client ) - self.safe_address = "0x7552Ed65a45E27740a15B8D5415E90d8ca64C109" + self.safe_address = "0x24833C9c4644a70250BCCBcB5f8529b609eaE6EC" def test_constructor(self): - ethereum_network = EthereumNetwork.RINKEBY + ethereum_network = EthereumNetwork.GOERLI base_url = "https://safe.global" transaction_service_api = TransactionServiceApi( ethereum_network, ethereum_client=None, base_url=base_url @@ -30,7 +30,7 @@ def test_from_ethereum_client(self): TransactionServiceApi.from_ethereum_client(self.ethereum_client) with mock.patch.object( - EthereumClient, "get_network", return_value=EthereumNetwork.RINKEBY + EthereumClient, "get_network", return_value=EthereumNetwork.GOERLI ): transaction_service_api = TransactionServiceApi.from_ethereum_client( self.ethereum_client @@ -38,7 +38,7 @@ def test_from_ethereum_client(self): self.assertEqual( transaction_service_api.ethereum_client, self.ethereum_client ) - self.assertEqual(transaction_service_api.network, EthereumNetwork.RINKEBY) + self.assertEqual(transaction_service_api.network, EthereumNetwork.GOERLI) def test_data_decoded_to_text(self): test_data = { diff --git a/gnosis/safe/tests/test_safe.py b/gnosis/safe/tests/test_safe.py index c4a17d686..53a7998d6 100644 --- a/gnosis/safe/tests/test_safe.py +++ b/gnosis/safe/tests/test_safe.py @@ -43,6 +43,13 @@ def test_create(self): self.assertEqual(safe.retrieve_owners(), owners) self.assertEqual(safe.retrieve_threshold(), threshold) + def test_domain_separator(self): + safe = self.deploy_test_safe() + self.assertTrue(safe.domain_separator) + + not_a_safe = Account.create().address + self.assertIsNone(Safe(not_a_safe, self.ethereum_client).domain_separator) + def test_check_funds_for_tx_gas(self): safe = self.deploy_test_safe() safe_tx_gas = 2 diff --git a/gnosis/safe/tests/test_signatures.py b/gnosis/safe/tests/test_signatures.py index 5aa98b248..43a881ee0 100644 --- a/gnosis/safe/tests/test_signatures.py +++ b/gnosis/safe/tests/test_signatures.py @@ -4,11 +4,16 @@ from web3 import Web3 from gnosis.eth.constants import NULL_ADDRESS +from gnosis.eth.contracts import get_compatibility_fallback_handler_V1_3_0_contract from ..signatures import get_signing_address +from .safe_test_case import SafeTestCaseMixin -class TestSafeSignature(TestCase): +class TestSafeSignature(SafeTestCaseMixin, TestCase): + EIP1271_MAGIC_VALUE = "20c13b0b" + EIP1271_MAGIC_VALUE_UPDATED = "1626ba7e" + def test_get_signing_address(self): account = Account.create() # Random hash @@ -24,3 +29,75 @@ def test_get_signing_address(self): get_signing_address(random_hash, signature.v - 8, signature.r, signature.s), NULL_ADDRESS, ) + + def test_eip1271_signing(self): + owner = Account.create() + message = "luar_na_lubre" + safe = self.deploy_test_safe(threshold=1, owners=[owner.address]) + + self.assertEqual( + safe.contract.functions.domainSeparator().call(), safe.domain_separator + ) + + compatibility_contract = get_compatibility_fallback_handler_V1_3_0_contract( + self.w3, safe.address + ) + safe_message_hash = safe.get_message_hash(message) + self.assertEqual( + compatibility_contract.functions.getMessageHash(message.encode()).call(), + safe_message_hash, + ) + + # Use deprecated isValidSignature method (receives bytes) + signature = owner.signHash(safe_message_hash) + is_valid_bytes_fn = compatibility_contract.get_function_by_signature( + "isValidSignature(bytes,bytes)" + ) + self.assertEqual( + is_valid_bytes_fn(message.encode(), signature.signature).call(), + bytes.fromhex(self.EIP1271_MAGIC_VALUE), + ) + + # Use new isValidSignature method (receives bytes32 == hash of the message) + # Message needs to be hashed first + message_hash = Web3.keccak(text=message) + safe_message_hash = safe.get_message_hash(message_hash) + self.assertEqual( + compatibility_contract.functions.getMessageHash(message_hash).call(), + safe_message_hash, + ) + + signature = owner.signHash(safe_message_hash) + is_valid_bytes_fn = compatibility_contract.get_function_by_signature( + "isValidSignature(bytes32,bytes)" + ) + self.assertEqual( + is_valid_bytes_fn(message_hash, signature.signature).call(), + bytes.fromhex(self.EIP1271_MAGIC_VALUE_UPDATED), + ) + + def test_eip1271_signing_v1_1_1_contract(self): + owner = Account.create() + message = "luar_na_lubre" + safe = self.deploy_test_safe_v1_1_1(threshold=1, owners=[owner.address]) + + self.assertEqual( + safe.contract.functions.domainSeparator().call(), safe.domain_separator + ) + + contract = safe.contract + safe_message_hash = safe.get_message_hash(message) + self.assertEqual( + contract.functions.getMessageHash(message.encode()).call(), + safe_message_hash, + ) + + # Use isValidSignature method (receives bytes) + signature = owner.signHash(safe_message_hash) + + self.assertEqual( + contract.functions.isValidSignature( + message.encode(), signature.signature + ).call(), + bytes.fromhex(self.EIP1271_MAGIC_VALUE), + ) diff --git a/gnosis/util/__init__.py b/gnosis/util/__init__.py index d518b2257..48dbc2366 100644 --- a/gnosis/util/__init__.py +++ b/gnosis/util/__init__.py @@ -1,4 +1,4 @@ # flake8: noqa F401 -from .util import cache, cached_property, chunks +from .util import cache, chunks -__all__ = ["chunks", "cached_property", "cache"] +__all__ = ["cache", "chunks"] diff --git a/gnosis/util/util.py b/gnosis/util/util.py index 0c6adbd74..1351b499b 100644 --- a/gnosis/util/util.py +++ b/gnosis/util/util.py @@ -1,11 +1,5 @@ from typing import Any, Iterable, List -try: - from functools import cached_property # noqa F401 -except ImportError: - from cached_property import cached_property # noqa F401 - - try: from functools import cache except ImportError: diff --git a/gp_cli.py b/gp_cli.py index 484460ddf..f53008eb9 100644 --- a/gp_cli.py +++ b/gp_cli.py @@ -63,7 +63,7 @@ def confirm_prompt(question: str) -> bool: buyTokenBalance="erc20", # `erc20` or `internal` ) - order["feeAmount"] = gnosis_protocol_api.get_fee(order) + order.feeAmount = gnosis_protocol_api.get_fee(order) if confirm_prompt( f"Exchanging {amount_wei} {from_token} to {buy_amount} {to_token}?" diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index b0f076532..000000000 --- a/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build-system] -requires = ["setuptools>=42"] -build-backend = "setuptools.build_meta" diff --git a/requirements-test.txt b/requirements-test.txt index 9a1552a24..aa6168fd5 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,7 +1,8 @@ -r requirements.txt -coverage==6.4.4 -faker==14.2.0 -pytest==7.1.3 +coverage==6.5.0 +faker==16.6.1 +pytest==7.2.1 pytest-django==4.5.2 -pytest-rerunfailures==10.2 -pytest-sugar==0.9.5 +pytest-env==0.8.1 +pytest-rerunfailures==11.0 +pytest-sugar==0.9.6 diff --git a/requirements.txt b/requirements.txt index 471b56bac..c78c88c98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,10 @@ -cached-property==1.5.2 -django==3.2.15 +django==4.1.5 django-filter==22.1 -djangorestframework==3.13.1 -eip712-structs==1.1.0 +djangorestframework==3.14.0 hexbytes==0.2.3 packaging -psycopg2-binary==2.9.3 +psycopg2-binary==2.9.5 py-evm==0.5.0a3 -pysha3>=1.0.0 -requests==2.28.1 -typing-extensions==3.10.0.2 -web3==5.30.0 +pysha3>=1.0.2 +requests==2.28.2 +web3==5.31.3 diff --git a/setup.cfg b/setup.cfg index 7e26df1dc..9a91204ba 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,69 @@ +[metadata] +name = safe-eth-py +version = 4.9.3 +description = Safe Ecosystem Foundation utilities for Ethereum projects +long_description = file: README.rst +long_description_content_type = text/x-rst; charset=UTF-8 +keywords = + ethereum + web3 + django + safe + cowswap + gnosis +url = https://github.com/safe-global/safe-eth-py +author = Uxío +author_email = uxio@safe.global +license = MIT License +license_files = + LICENSE +classifiers = + Environment :: Web Environment + Framework :: Django + Framework :: Django :: 2.0 + Framework :: Django :: 3.0 + Framework :: Django :: 4.0 + Intended Audience :: Developers + License :: OSI Approved :: MIT License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Topic :: Internet :: WWW/HTTP + Topic :: Internet :: WWW/HTTP :: Dynamic Content +project_urls = + Documentation = https://safe-eth-py.readthedocs.io/en/latest/ + Source = https://github.com/safe-global/safe-eth-py + Tracker = https://github.com/safe-global/safe-eth-py/issues + +[options] +packages = find: +platforms = any +include_package_data = True +install_requires = + packaging + py-evm==0.5.0a3 + pysha3>=1.0.0 + requests>=2 + web3>=5.23.0 +python_requires = >=3.8 + +[options.package_data] +gnosis = + py.typed + *.json + +[options.extras_require] +django = + django>=2 + django-filter>=2 + djangorestframework>=2 + [flake8] max-line-length = 88 select = C,E,F,W,B,B950 -extend-ignore = E203, E501 +extend-ignore = E203,E501,F841,W503 exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv [pycodestyle] @@ -15,3 +77,37 @@ known_first_party = gnosis known_gnosis = py_eth_sig_utils known_django = django sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,GNOSIS,FIRSTPARTY,LOCALFOLDER + +[tool:pytest] +env = + DJANGO_SETTINGS_MODULE=config.settings.test + +[mypy] +python_version = 3.10 +check_untyped_defs = True +ignore_missing_imports = True +warn_unused_ignores = True +warn_redundant_casts = True +warn_unused_configs = True + +[coverage:report] +exclude_lines = +# Have to re-enable the standard pragma + pragma: no cover + + # Don't complain if tests don't hit defensive assertion code: + raise NotImplementedError + + # Don't complain if non-runnable code isn't run: + if __name__ == .__main__.: + if settings.DEBUG + + # Ignore pass lines + pass + +[coverage:run] +include = safe_transaction_service/* +omit = + *__init__.py* + *tests* + */migrations/* diff --git a/setup.py b/setup.py index 0bfa91364..1abbd068c 100644 --- a/setup.py +++ b/setup.py @@ -1,56 +1,4 @@ -import os +import setuptools -from setuptools import find_packages, setup - -with open(os.path.join(os.path.dirname(__file__), "README.rst")) as readme: - README = readme.read() - -# allow setup.py to be run from any path -os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) - -requirements = [ - "cached-property>=1.5; python_version < '3.8'", - "eip712_structs", - "packaging", - "py-evm==0.5.0a3", - "pysha3>=1.0.0", - "requests>=2", - "typing-extensions>=3.10; python_version < '3.8'", - "web3>=5.23.0", -] - -extras_require = {"django": ["django>=2", "django-filter>=2", "djangorestframework>=2"]} - -setup( - name="safe-eth-py", - version="4.4.0", - packages=find_packages(), - package_data={"gnosis": ["py.typed"]}, - install_requires=requirements, - include_package_data=True, - extras_require=extras_require, - python_requires=">=3.7", - license="MIT License", - description="Gnosis libraries for Python Projects", - long_description=README, - url="https://github.com/safe-global/safe-eth-py", - author="Uxío", - author_email="uxio@safe.global", - keywords=["ethereum", "web3", "django", "rest", "gnosis"], - classifiers=[ - "Environment :: Web Environment", - "Framework :: Django", - "Framework :: Django :: 2.0", - "Framework :: Django :: 3.0", - "Framework :: Django :: 4.0", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Internet :: WWW/HTTP :: Dynamic Content", - ], -) +if __name__ == "__main__": + setuptools.setup()