diff --git a/README.md b/README.md index a140a0d..c3b87f3 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,34 @@ Generate the SDK from the CoW Protocol smart contracts, Subgraph, and Orderbook make codegen ``` +## 🐄 Development + +### 🐄 Tests + +Run tests to ensure everything's working: + +```bash +make test # or poetry run pytest +``` + +### 🐄 Formatting/Linting + +Run the formatter and linter: + +```bash +make format # or ruff check . --fix +make lint # or ruff format +``` + +### 🐄 Codegen + +Generate the SDK from the CoW Protocol smart contracts, Subgraph, and Orderbook API: + +```bash +make codegen +``` + + ## 🐄 Contributing to the Herd Interested in contributing? Here's how you can help: diff --git a/cow_py/app_data/__init__.py b/cow_py/app_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cow_py/app_data/app_data_cid.py b/cow_py/app_data/app_data_cid.py new file mode 100644 index 0000000..df4327b --- /dev/null +++ b/cow_py/app_data/app_data_cid.py @@ -0,0 +1,15 @@ +from typing import Any, Dict + +from cow_py.app_data.consts import DEFAULT_IPFS_READ_URI +from cow_py.app_data.utils import extract_digest, fetch_doc_from_cid + + +class AppDataCid: + def __init__(self, app_data_cid: str): + self.app_data_cid = app_data_cid + + async def to_doc(self, ipfs_uri: str = DEFAULT_IPFS_READ_URI) -> Dict[str, Any]: + return await fetch_doc_from_cid(self.app_data_cid, ipfs_uri) + + def to_hex(self) -> str: + return extract_digest(self.app_data_cid) diff --git a/cow_py/app_data/app_data_doc.py b/cow_py/app_data/app_data_doc.py new file mode 100644 index 0000000..e16b438 --- /dev/null +++ b/cow_py/app_data/app_data_doc.py @@ -0,0 +1,30 @@ +from typing import Dict, Any + +from eth_utils.crypto import keccak + +from cow_py.app_data.app_data_hex import AppDataHex +from cow_py.app_data.consts import DEFAULT_APP_DATA_DOC +from cow_py.app_data.utils import stringify_deterministic + + +class AppDataDoc: + def __init__( + self, app_data_doc: Dict[str, Any] = {}, app_data_doc_string: str = "" + ): + self.app_data_doc = {**DEFAULT_APP_DATA_DOC, **app_data_doc} + self.app_data_doc_string = app_data_doc_string + + def to_string(self) -> str: + if self.app_data_doc_string: + return self.app_data_doc_string + return stringify_deterministic(self.app_data_doc) + + def to_hex(self) -> str: + # TODO: add validation of app data + full_app_data_json = self.to_string() + data_bytes = full_app_data_json.encode("utf-8") + return "0x" + keccak(data_bytes).hex() + + def to_cid(self) -> str: + appDataHex = AppDataHex(self.to_hex()[2:]) + return appDataHex.to_cid() diff --git a/cow_py/app_data/app_data_hex.py b/cow_py/app_data/app_data_hex.py new file mode 100644 index 0000000..356ed60 --- /dev/null +++ b/cow_py/app_data/app_data_hex.py @@ -0,0 +1,63 @@ +from typing import Dict, Any +from web3 import Web3 +from multiformats import multibase + +from cow_py.app_data.consts import DEFAULT_IPFS_READ_URI, MetaDataError +from cow_py.app_data.utils import fetch_doc_from_cid + +CID_V1_PREFIX = 0x01 +CID_RAW_MULTICODEC = 0x55 +KECCAK_HASHING_ALGORITHM = 0x1B +KECCAK_HASHING_LENGTH = 32 +CID_DAG_PB_MULTICODEC = 0x70 +SHA2_256_HASHING_ALGORITHM = 0x12 +SHA2_256_HASHING_LENGTH = 32 + + +class AppDataHex: + def __init__(self, app_data_hex: str): + self.app_data_hex = app_data_hex + + def to_cid(self) -> str: + cid = self._app_data_hex_to_cid() + self._assert_cid(cid) + return cid + + async def to_doc(self, ipfs_uri: str = DEFAULT_IPFS_READ_URI) -> Dict[str, Any]: + try: + cid = self.to_cid() + return await fetch_doc_from_cid(cid, ipfs_uri) + except Exception as e: + raise MetaDataError( + f"Unexpected error decoding AppData: appDataHex={self.app_data_hex}, message={e}" + ) + + def _assert_cid(self, cid: str): + if not cid: + raise MetaDataError( + f"Error getting CID from appDataHex: {self.app_data_hex}" + ) + + def _app_data_hex_to_cid(self) -> str: + cid_bytes = self._to_cid_bytes( + { + "version": CID_V1_PREFIX, + "multicodec": CID_RAW_MULTICODEC, + "hashing_algorithm": KECCAK_HASHING_ALGORITHM, + "hashing_length": KECCAK_HASHING_LENGTH, + "multihash_hex": self.app_data_hex, + } + ) + return multibase.encode(cid_bytes, "base16") + + def _to_cid_bytes(self, params: Dict[str, Any]) -> bytes: + hash_bytes = Web3.to_bytes(hexstr=params["multihash_hex"]) + cid_prefix = bytes( + [ + params["version"], + params["multicodec"], + params["hashing_algorithm"], + params["hashing_length"], + ] + ) + return cid_prefix + hash_bytes diff --git a/cow_py/app_data/consts.py b/cow_py/app_data/consts.py new file mode 100644 index 0000000..4f7e2c4 --- /dev/null +++ b/cow_py/app_data/consts.py @@ -0,0 +1,15 @@ +import os + +DEFAULT_IPFS_READ_URI = os.getenv("IPFS_READ_URI", "https://cloudflare-ipfs.com/ipfs") + +LATEST_APP_DATA_VERSION = "1.1.0" +DEFAULT_APP_CODE = "CoW Swap" +DEFAULT_APP_DATA_DOC = { + "appCode": DEFAULT_APP_CODE, + "metadata": {}, + "version": LATEST_APP_DATA_VERSION, +} + + +class MetaDataError(Exception): + pass diff --git a/cow_py/app_data/utils.py b/cow_py/app_data/utils.py new file mode 100644 index 0000000..2003066 --- /dev/null +++ b/cow_py/app_data/utils.py @@ -0,0 +1,39 @@ +from typing import Any, Dict +import httpx +from multiformats import CID +from collections.abc import Mapping + + +import json + +from cow_py.app_data.consts import DEFAULT_IPFS_READ_URI + +# CID uses multibase to self-describe the encoding used (See https://github.com/multiformats/multibase) +# - Most reference implementations (multiformats/cid or Pinata, etc) use base58btc encoding +# - However, the backend uses base16 encoding (See https://github.com/cowprotocol/services/blob/main/crates/app-data-hash/src/lib.rs#L64) +MULTIBASE_BASE16 = "f" + + +def extract_digest(cid_str: str) -> str: + cid = CID.decode(cid_str) + return "0x" + cid.raw_digest.hex() + + +def sort_nested_dict(d): + return { + k: sort_nested_dict(v) if isinstance(v, Mapping) else v + for k, v in sorted(d.items()) + } + + +def stringify_deterministic(obj): + sorted_dict = sort_nested_dict(obj) + return json.dumps(sorted_dict, sort_keys=True, separators=(",", ":")) + + +async def fetch_doc_from_cid( + cid: str, ipfs_uri: str = DEFAULT_IPFS_READ_URI +) -> Dict[str, Any]: + async with httpx.AsyncClient() as client: + response = await client.get(f"{ipfs_uri}/{cid}") + return response.json() diff --git a/poetry.lock b/poetry.lock index f0281fb..6c1e256 100644 --- a/poetry.lock +++ b/poetry.lock @@ -295,6 +295,24 @@ files = [ {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] +[[package]] +name = "bases" +version = "0.3.0" +description = "Python library for general Base-N encodings." +optional = false +python-versions = ">=3.7" +files = [ + {file = "bases-0.3.0-py3-none-any.whl", hash = "sha256:a2fef3366f3e522ff473d2e95c21523fe8e44251038d5c6150c01481585ebf5b"}, + {file = "bases-0.3.0.tar.gz", hash = "sha256:70f04a4a45d63245787f9e89095ca11042685b6b64b542ad916575ba3ccd1570"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0" +typing-validation = ">=1.1.0" + +[package.extras] +dev = ["base58", "mypy", "pylint", "pytest", "pytest-cov"] + [[package]] name = "bitarray" version = "2.9.2" @@ -2001,6 +2019,44 @@ files = [ [package.dependencies] typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} +[[package]] +name = "multiformats" +version = "0.3.1.post4" +description = "Python implementation of multiformats protocols." +optional = false +python-versions = ">=3.7" +files = [ + {file = "multiformats-0.3.1.post4-py3-none-any.whl", hash = "sha256:5b1d61bd8275c9e817bdbee38dbd501b26629011962ee3c86c46e7ccd0b14129"}, + {file = "multiformats-0.3.1.post4.tar.gz", hash = "sha256:d00074fdbc7d603c2084b4c38fa17bbc28173cf2750f51f46fbbc5c4d5605fbb"}, +] + +[package.dependencies] +bases = ">=0.3.0" +multiformats-config = ">=0.3.0" +typing-extensions = ">=4.6.0" +typing-validation = ">=1.1.0" + +[package.extras] +dev = ["blake3", "mmh3", "mypy", "pycryptodomex", "pylint", "pyskein", "pytest", "pytest-cov", "rich"] +full = ["blake3", "mmh3", "pycryptodomex", "pyskein", "rich"] + +[[package]] +name = "multiformats-config" +version = "0.3.1" +description = "Pre-loading configuration module for the 'multiformats' package." +optional = false +python-versions = ">=3.7" +files = [ + {file = "multiformats-config-0.3.1.tar.gz", hash = "sha256:7eaa80ef5d9c5ee9b86612d21f93a087c4a655cbcb68960457e61adbc62b47a7"}, + {file = "multiformats_config-0.3.1-py3-none-any.whl", hash = "sha256:dec4c9d42ed0d9305889b67440f72e8e8d74b82b80abd7219667764b5b0a8e1d"}, +] + +[package.dependencies] +multiformats = "*" + +[package.extras] +dev = ["mypy", "pylint", "pytest", "pytest-cov"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -3218,6 +3274,23 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "typing-validation" +version = "1.2.11.post4" +description = "A simple library for runtime type-checking." +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_validation-1.2.11.post4-py3-none-any.whl", hash = "sha256:73dd504ddebf5210e80d5f65ba9b30efbd0fa42f266728fda7c4f0ba335c699c"}, + {file = "typing_validation-1.2.11.post4.tar.gz", hash = "sha256:7aed04ecfbda07e63b7266f90e5d096f96344f7facfe04bb081b21e4a9781670"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["mypy", "pylint", "pytest", "pytest-cov", "rich"] + [[package]] name = "urllib3" version = "2.2.2" @@ -3506,4 +3579,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "b560a5c3845be211a0319c912867980bc038e467912ae8cb16ef93a15ce87604" +content-hash = "764273bcc99bc9be5abd014f48e0eac375651b9fd4d48b7d4b6a78fe56a15f2e" diff --git a/pyproject.toml b/pyproject.toml index 91e733c..b41c6db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ pydantic = "^2.7.0" pytest-mock = "^3.14.0" backoff = "^2.2.1" aiolimiter = "^1.1.0" +multiformats = "^0.3.1.post4" [tool.poetry.group.dev.dependencies] diff --git a/tests/app_data/__init__.py b/tests/app_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app_data/app_data_cid.py b/tests/app_data/app_data_cid.py new file mode 100644 index 0000000..6ca50bc --- /dev/null +++ b/tests/app_data/app_data_cid.py @@ -0,0 +1,44 @@ +import httpx +import pytest +from unittest.mock import patch +from cow_py.app_data.app_data_cid import AppDataCid +from cow_py.app_data.consts import DEFAULT_IPFS_READ_URI +from .mocks import APP_DATA_HEX, CID, APP_DATA_HEX_2, CID_2 + + +@pytest.mark.asyncio +async def test_fetch_doc_from_cid(): + valid_serialized_cid = "QmZZhNnqMF1gRywNKnTPuZksX7rVjQgTT3TJAZ7R6VE3b2" + expected = { + "appCode": "CowSwap", + "metadata": { + "referrer": { + "address": "0x1f5B740436Fc5935622e92aa3b46818906F416E9", + "version": "0.1.0", + } + }, + "version": "0.1.0", + } + + with patch("httpx.AsyncClient.get") as mock_get: + mock_get.return_value = httpx.Response(200, json=expected) + + app_data_hex = AppDataCid(valid_serialized_cid) + app_data_document = await app_data_hex.to_doc() + + assert app_data_document == expected + mock_get.assert_called_once_with(f"{DEFAULT_IPFS_READ_URI}/{valid_serialized_cid}") + + +def test_app_data_cid_to_hex(): + decoded_app_data_hex = CID.to_hex() + assert decoded_app_data_hex == APP_DATA_HEX.app_data_hex + + decoded_app_data_hex_2 = CID_2.to_hex() + assert decoded_app_data_hex_2 == APP_DATA_HEX_2.app_data_hex + + +def test_app_data_cid_to_hex_invalid_hash(): + app_data_cid = AppDataCid("invalidCid") + with pytest.raises(Exception): + app_data_cid.to_hex() diff --git a/tests/app_data/app_data_doc.py b/tests/app_data/app_data_doc.py new file mode 100644 index 0000000..7ec157b --- /dev/null +++ b/tests/app_data/app_data_doc.py @@ -0,0 +1,58 @@ +from cow_py.app_data.app_data_doc import AppDataDoc +from .mocks import ( + APP_DATA_2, + APP_DATA_DOC, + APP_DATA_DOC_CUSTOM_VALUES, + APP_DATA_HEX, + APP_DATA_HEX_2, + APP_DATA_STRING, + APP_DATA_STRING_2, + CID, + CID_2, +) + + +def test_init_empty_metadata(): + app_data_doc = AppDataDoc() + assert app_data_doc.app_data_doc.get("version") + assert app_data_doc.app_data_doc.get("metadata") == {} + assert app_data_doc.app_data_doc.get("appCode") == "CoW Swap" + assert app_data_doc.app_data_doc.get("environment") is None + + +def test_init_custom_metadata(): + app_data_doc = AppDataDoc(APP_DATA_DOC_CUSTOM_VALUES) + assert app_data_doc.app_data_doc + assert app_data_doc.app_data_doc.get("metadata") == APP_DATA_DOC_CUSTOM_VALUES.get( + "metadata" + ) + assert app_data_doc.app_data_doc.get("appCode") == APP_DATA_DOC_CUSTOM_VALUES.get( + "appCode" + ) + assert app_data_doc.app_data_doc.get( + "environment" + ) == APP_DATA_DOC_CUSTOM_VALUES.get("environment") + + +def test_app_data_doc_to_string(): + string_1 = APP_DATA_DOC.to_string() + assert string_1 == APP_DATA_STRING + + string_2 = APP_DATA_2.to_string() + assert string_2 == APP_DATA_STRING_2 + + +def test_app_data_doc_to_hex(): + hex_1 = APP_DATA_DOC.to_hex() + assert hex_1 == APP_DATA_HEX.app_data_hex + + hex_2 = APP_DATA_2.to_hex() + assert hex_2 == APP_DATA_HEX_2.app_data_hex + + +def test_app_data_doc_to_cid(): + cid_1 = APP_DATA_DOC.to_cid() + assert cid_1 == CID.app_data_cid + + cid_2 = APP_DATA_2.to_cid() + assert cid_2 == CID_2.app_data_cid diff --git a/tests/app_data/app_data_hex.py b/tests/app_data/app_data_hex.py new file mode 100644 index 0000000..69651dd --- /dev/null +++ b/tests/app_data/app_data_hex.py @@ -0,0 +1,32 @@ +import httpx +import pytest +from unittest.mock import patch +from cow_py.app_data.consts import MetaDataError +from cow_py.app_data.app_data_hex import AppDataHex +from .mocks import ( + APP_DATA_HEX, + CID, + HTTP_STATUS_INTERNAL_ERROR, +) + + +def test_app_data_hex_to_cid(): + decoded_app_data_cid = APP_DATA_HEX.to_cid() + assert decoded_app_data_cid == CID.app_data_cid + + +def test_app_data_hex_to_cid_invalid_hash(): + app_data_hex = AppDataHex("invalidHash") + with pytest.raises(Exception): + app_data_hex.to_cid() + + +@pytest.mark.asyncio +async def test_fetch_doc_from_app_data_hex_invalid_hash(): + with patch("httpx.AsyncClient.get") as mock_get: + mock_get.return_value = httpx.Response(HTTP_STATUS_INTERNAL_ERROR) + + app_data_hex = AppDataHex("invalidHash") + + with pytest.raises(MetaDataError): + await app_data_hex.to_doc() diff --git a/tests/app_data/mocks.py b/tests/app_data/mocks.py new file mode 100644 index 0000000..9a2cf58 --- /dev/null +++ b/tests/app_data/mocks.py @@ -0,0 +1,71 @@ +from cow_py.app_data.app_data_cid import AppDataCid +from cow_py.app_data.app_data_doc import AppDataDoc +from cow_py.app_data.app_data_hex import AppDataHex + +HTTP_STATUS_OK = 200 +HTTP_STATUS_INTERNAL_ERROR = 500 + +APP_DATA_STRING = '{"appCode":"CoW Swap","metadata":{},"version":"0.7.0"}' +APP_DATA_DOC = AppDataDoc( + { + "version": "0.7.0", + "appCode": "CoW Swap", + "metadata": {}, + }, + APP_DATA_STRING, +) + + +CID = AppDataCid( + "f01551b20337aa6e6c2a7a0d1eb79a35ebd88b08fc963d5f7a3fc953b7ffb2b7f5898a1df" +) + +APP_DATA_HEX = AppDataHex( + "0x337aa6e6c2a7a0d1eb79a35ebd88b08fc963d5f7a3fc953b7ffb2b7f5898a1df" +) + +APP_DATA_DOC_CUSTOM_VALUES = { + **APP_DATA_DOC.app_data_doc, + "environment": "test", + "metadata": { + "referrer": { + "address": "0x1f5B740436Fc5935622e92aa3b46818906F416E9", + "version": "0.1.0", + }, + "quote": { + "slippageBips": 1, + "version": "0.2.0", + }, + }, +} + +APP_DATA_STRING_2 = '{"appCode":"CoW Swap","environment":"production","metadata":{"quote":{"slippageBips":"50","version":"0.2.0"},"orderClass":{"orderClass":"market","version":"0.1.0"}},"version":"0.6.0"}' + +APP_DATA_2 = AppDataDoc( + { + "appCode": "CoW Swap", + "environment": "production", + "metadata": { + "quote": {"slippageBips": "50", "version": "0.2.0"}, + "orderClass": {"orderClass": "market", "version": "0.1.0"}, + }, + "version": "0.6.0", + }, + APP_DATA_STRING_2, +) + + +CID_2 = AppDataCid( + "f01551b208af4e8c9973577b08ac21d17d331aade86c11ebcc5124744d621ca8365ec9424" +) + +APP_DATA_HEX_2 = AppDataHex( + "0x8af4e8c9973577b08ac21d17d331aade86c11ebcc5124744d621ca8365ec9424" +) + + +PINATA_API_KEY = "apikey" +PINATA_API_SECRET = "apiSecret" + +IPFS_HASH = "QmU4j5Y6JM9DqQ6yxB6nMHq4GChWg1zPehs1U7nGPHABRu" +IPFS_HASH_DIGEST = "0x5511c4eac66ab272d9a6ab90e07977d00ff7375fc4dc1038a3c05b2c16ca0b74" diff --git a/tests/app_data/utils.py b/tests/app_data/utils.py new file mode 100644 index 0000000..b116501 --- /dev/null +++ b/tests/app_data/utils.py @@ -0,0 +1,50 @@ +from cow_py.app_data.utils import stringify_deterministic + + +def test_stringify_deterministic_simple_object(): + node = {"c": 6, "b": [4, 5], "a": 3, "z": None} + actual = stringify_deterministic(node) + expected = '{"a":3,"b":[4,5],"c":6,"z":null}' + assert actual == expected + + +def test_stringify_deterministic_object_with_empty_string(): + node = {"a": 3, "z": ""} + actual = stringify_deterministic(node) + expected = '{"a":3,"z":""}' + assert actual == expected + + +def test_stringify_deterministic_nested_object(): + node = {"a": {"b": {"c": [1, 2, 3, None]}}} + actual = stringify_deterministic(node) + expected = '{"a":{"b":{"c":[1,2,3,null]}}}' + assert actual == expected + + +def test_stringify_deterministic_array_with_objects(): + node = [{"z": 1, "a": 2}] + actual = stringify_deterministic(node) + expected = '[{"a":2,"z":1}]' + assert actual == expected + + +def test_stringify_deterministic_nested_array_objects(): + node = [{"z": [[{"y": 1, "b": 2}]], "a": 2}] + actual = stringify_deterministic(node) + expected = '[{"a":2,"z":[[{"b":2,"y":1}]]}]' + assert actual == expected + + +def test_stringify_deterministic_array_with_none(): + node = [1, None] + actual = stringify_deterministic(node) + expected = "[1,null]" + assert actual == expected + + +def test_stringify_deterministic_array_with_empty_string(): + node = [1, ""] + actual = stringify_deterministic(node) + expected = '[1,""]' + assert actual == expected