From 0f163e8f83141406b72355e9d63e79252bdb5dfb Mon Sep 17 00:00:00 2001 From: Pedro Yves Fracari <55461956+yvesfracari@users.noreply.github.com> Date: Fri, 6 Sep 2024 12:20:49 -0300 Subject: [PATCH] #6 breakdown pt2 - add contracts module (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add new repo structure * add codegen (contracts, subgraph), order signing and order book API interface * refactor repo configs/readme * add codegen module * add common module * remove stale subgraphs/core modules * add contracts module * add order_book module * add order posting example * remove web3_codegen from Makefile * remove order signing TODO from readme * fix PR reviews * remove duplicated ETH constant address * fix lint * remove eth but address constant * add types of domain and add order hashing tests * fix types * fix type in order_posting_e2e test * add generated files to lib --------- Co-authored-by: José Ribeiro --- README.md | 104 ++- cow_py/codegen/__generated__/ComposableCow.py | 143 ++++ .../ExtensibleFallbackHandler.py | 101 +++ cow_py/codegen/__generated__/TWAP.py | 93 +++ cow_py/common/constants.py | 2 - cow_py/contracts/domain.py | 35 + cow_py/contracts/order.py | 260 +++++++ cow_py/contracts/sign.py | 122 +++ cow_py/order_book/api.py | 203 +++++ cow_py/order_book/config.py | 39 + cow_py/order_book/generated/model.py | 711 ++++++++++++++++++ cow_py/web3/provider.py | 1 + examples/__init__.py | 0 examples/order_posting_e2e.py | 116 +++ tests/contracts/conftest.py | 47 ++ tests/contracts/test_orders.py | 104 +++ tests/contracts/test_sign.py | 71 ++ tests/order_book/test_api.py | 147 ++++ 18 files changed, 2287 insertions(+), 12 deletions(-) create mode 100644 cow_py/codegen/__generated__/ComposableCow.py create mode 100644 cow_py/codegen/__generated__/ExtensibleFallbackHandler.py create mode 100644 cow_py/codegen/__generated__/TWAP.py create mode 100644 cow_py/contracts/domain.py create mode 100644 cow_py/contracts/order.py create mode 100644 cow_py/contracts/sign.py create mode 100644 cow_py/order_book/api.py create mode 100644 cow_py/order_book/config.py create mode 100644 cow_py/order_book/generated/model.py create mode 100644 examples/__init__.py create mode 100644 examples/order_posting_e2e.py create mode 100644 tests/contracts/conftest.py create mode 100644 tests/contracts/test_orders.py create mode 100644 tests/contracts/test_sign.py create mode 100644 tests/order_book/test_api.py diff --git a/README.md b/README.md index 43b79ff..16982cc 100644 --- a/README.md +++ b/README.md @@ -87,20 +87,104 @@ data = client.get_data(response) pprint(data) ``` -### Signing an Order (TODO) +Or you can leverage `SubgraphClient` to use a custom query and get the results as JSON: ```python -from cow_py.order_signing import sign_order +from pprint import pprint +from cow_py.subgraph.client import SubgraphClient + +url = build_subgraph_url() # Default network is Chain.MAINNET and env SubgraphEnvironment.PRODUCTION +client = SubgraphClient(url=url) + +response = await client.execute(query=""" + query LastDaysVolume($days: Int!) { + dailyTotals(orderBy: timestamp, orderDirection: desc, first: $days) { + timestamp + volumeUsd + } + } + """, variables=dict(days=2) + ) + +data = client.get_data(response) +pprint(data) +``` + +Or you can leverage `SubgraphClient` to use a custom query and get the results as JSON: + +```python +from pprint import pprint +from cow_py.subgraph.client import SubgraphClient + +url = build_subgraph_url() # Default network is Chain.MAINNET and env SubgraphEnvironment.PRODUCTION +client = SubgraphClient(url=url) + +response = await client.execute(query=""" + query LastDaysVolume($days: Int!) { + dailyTotals(orderBy: timestamp, orderDirection: desc, first: $days) { + timestamp + volumeUsd + } + } + """, variables=dict(days=2) + ) + +data = client.get_data(response) +pprint(data) +``` + +## 🐄 Development -# Example order details -order_details = { - "sell_token": "0x...", - "buy_token": "0x...", - "sell_amount": 100000, -} +### 🐄 Tests + +Run tests to ensure everything's working: + +```bash +make test # or poetry run pytest +``` + +### 🐄 Formatting/Linting -signed_order = sign_order(order_details, private_key="your_private_key") -print(signed_order) +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 +``` + +## 🐄 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 ``` ## 🐄 Development diff --git a/cow_py/codegen/__generated__/ComposableCow.py b/cow_py/codegen/__generated__/ComposableCow.py new file mode 100644 index 0000000..32d6f14 --- /dev/null +++ b/cow_py/codegen/__generated__/ComposableCow.py @@ -0,0 +1,143 @@ +from typing import List, Tuple +from hexbytes import HexBytes +from cow_py.common.chains import Chain +from dataclasses import dataclass +from cow_py.codegen.components import ( + BaseMixin, + BaseContract, + FileAbiLoader, + ContractFactory, + get_abi_file, +) + + +@dataclass +class IConditionalOrder_ConditionalOrderParams: + handler: str + salt: HexBytes + staticInput: HexBytes + + +@dataclass +class ComposableCoW_Proof: + location: int + data: HexBytes + + +@dataclass +class GPv2Order_Data: + sellToken: str + buyToken: str + receiver: str + sellAmount: int + buyAmount: int + validTo: int + appData: HexBytes + feeAmount: int + kind: HexBytes + partiallyFillable: bool + sellTokenBalance: HexBytes + buyTokenBalance: HexBytes + + +class ComposableCowMixin(BaseMixin): + def cabinet(self, str_arg_0: str, hexbytes_arg_0: HexBytes) -> HexBytes: + return self.call_contract_method("cabinet", str_arg_0, hexbytes_arg_0) + + def create( + self, params: IConditionalOrder_ConditionalOrderParams, dispatch: bool + ) -> None: + return self.call_contract_method( + "create", (params.handler, params.salt, params.staticInput), dispatch + ) + + def create_with_context( + self, + params: IConditionalOrder_ConditionalOrderParams, + factory: str, + data: HexBytes, + dispatch: bool, + ) -> None: + return self.call_contract_method( + "createWithContext", + (params.handler, params.salt, params.staticInput), + factory, + data, + dispatch, + ) + + def domain_separator(self) -> HexBytes: + return self.call_contract_method("domainSeparator") + + def get_tradeable_order_with_signature( + self, + owner: str, + params: IConditionalOrder_ConditionalOrderParams, + offchain_input: HexBytes, + proof: List[HexBytes], + ) -> Tuple[GPv2Order_Data, HexBytes]: + return self.call_contract_method( + "getTradeableOrderWithSignature", + owner, + (params.handler, params.salt, params.staticInput), + offchain_input, + proof, + ) + + def hash(self, params: IConditionalOrder_ConditionalOrderParams) -> HexBytes: + return self.call_contract_method( + "hash", (params.handler, params.salt, params.staticInput) + ) + + def is_valid_safe_signature( + self, + safe: str, + sender: str, + _hash: HexBytes, + _domain_separator: HexBytes, + hexbytes_arg_0: HexBytes, + encode_data: HexBytes, + payload: HexBytes, + ) -> HexBytes: + return self.call_contract_method( + "isValidSafeSignature", + safe, + sender, + _hash, + _domain_separator, + hexbytes_arg_0, + encode_data, + payload, + ) + + def remove(self, single_order_hash: HexBytes) -> None: + return self.call_contract_method("remove", single_order_hash) + + def roots(self, str_arg_0: str) -> HexBytes: + return self.call_contract_method("roots", str_arg_0) + + def set_root(self, root: HexBytes, proof: ComposableCoW_Proof) -> None: + return self.call_contract_method("setRoot", root, (proof.location, proof.data)) + + def set_root_with_context( + self, root: HexBytes, proof: ComposableCoW_Proof, factory: str, data: HexBytes + ) -> None: + return self.call_contract_method( + "setRootWithContext", root, (proof.location, proof.data), factory, data + ) + + def set_swap_guard(self, swap_guard: str) -> None: + return self.call_contract_method("setSwapGuard", swap_guard) + + def single_orders(self, str_arg_0: str, hexbytes_arg_0: HexBytes) -> bool: + return self.call_contract_method("singleOrders", str_arg_0, hexbytes_arg_0) + + def swap_guards(self, str_arg_0: str) -> str: + return self.call_contract_method("swapGuards", str_arg_0) + + +class ComposableCow(BaseContract, ComposableCowMixin): + def __init__(self, chain: Chain = Chain.MAINNET, address: str = ""): + abi_loader = FileAbiLoader(get_abi_file("ComposableCow")) + contract = ContractFactory.create("ComposableCow", chain, address, abi_loader) + super(ComposableCow, self).__init__(address, chain, abi=contract.ABI) diff --git a/cow_py/codegen/__generated__/ExtensibleFallbackHandler.py b/cow_py/codegen/__generated__/ExtensibleFallbackHandler.py new file mode 100644 index 0000000..02a9279 --- /dev/null +++ b/cow_py/codegen/__generated__/ExtensibleFallbackHandler.py @@ -0,0 +1,101 @@ +from typing import List +from hexbytes import HexBytes +from cow_py.common.chains import Chain +from cow_py.codegen.components import ( + BaseMixin, + BaseContract, + FileAbiLoader, + ContractFactory, + get_abi_file, +) + + +class ExtensibleFallbackHandlerMixin(BaseMixin): + def domain_verifiers(self, str_arg_0: str, hexbytes_arg_0: HexBytes) -> str: + return self.call_contract_method("domainVerifiers", str_arg_0, hexbytes_arg_0) + + def is_valid_signature(self, _hash: HexBytes, signature: HexBytes) -> HexBytes: + return self.call_contract_method("isValidSignature", _hash, signature) + + def on_erc_1155_batch_received( + self, + str_arg_0: str, + str_arg_1: str, + int_list_arg_0: List[int], + int_list_arg_1: List[int], + hexbytes_arg_0: HexBytes, + ) -> HexBytes: + return self.call_contract_method( + "onERC1155BatchReceived", + str_arg_0, + str_arg_1, + int_list_arg_0, + int_list_arg_1, + hexbytes_arg_0, + ) + + def on_erc_1155_received( + self, + str_arg_0: str, + str_arg_1: str, + int_arg_0: int, + int_arg_1: int, + hexbytes_arg_0: HexBytes, + ) -> HexBytes: + return self.call_contract_method( + "onERC1155Received", + str_arg_0, + str_arg_1, + int_arg_0, + int_arg_1, + hexbytes_arg_0, + ) + + def on_erc_721_received( + self, str_arg_0: str, str_arg_1: str, int_arg_0: int, hexbytes_arg_0: HexBytes + ) -> HexBytes: + return self.call_contract_method( + "onERC721Received", str_arg_0, str_arg_1, int_arg_0, hexbytes_arg_0 + ) + + def safe_interfaces(self, str_arg_0: str, hexbytes_arg_0: HexBytes) -> bool: + return self.call_contract_method("safeInterfaces", str_arg_0, hexbytes_arg_0) + + def safe_methods(self, str_arg_0: str, hexbytes_arg_0: HexBytes) -> HexBytes: + return self.call_contract_method("safeMethods", str_arg_0, hexbytes_arg_0) + + def set_domain_verifier( + self, domain_separator: HexBytes, new_verifier: str + ) -> None: + return self.call_contract_method( + "setDomainVerifier", domain_separator, new_verifier + ) + + def set_safe_method(self, selector: HexBytes, new_method: HexBytes) -> None: + return self.call_contract_method("setSafeMethod", selector, new_method) + + def set_supported_interface(self, interface_id: HexBytes, supported: bool) -> None: + return self.call_contract_method( + "setSupportedInterface", interface_id, supported + ) + + def set_supported_interface_batch( + self, _interface_id: HexBytes, handler_with_selectors: List[HexBytes] + ) -> None: + return self.call_contract_method( + "setSupportedInterfaceBatch", _interface_id, handler_with_selectors + ) + + def supports_interface(self, interface_id: HexBytes) -> bool: + return self.call_contract_method("supportsInterface", interface_id) + + +class ExtensibleFallbackHandler(BaseContract, ExtensibleFallbackHandlerMixin): + def __init__(self, chain: Chain = Chain.MAINNET, address: str = ""): + abi_loader = FileAbiLoader(get_abi_file("ExtensibleFallbackHandler")) + contract = ContractFactory.create( + "ExtensibleFallbackHandler", chain, address, abi_loader + ) + super(ExtensibleFallbackHandler, self).__init__( + address, chain, abi=contract.ABI + ) diff --git a/cow_py/codegen/__generated__/TWAP.py b/cow_py/codegen/__generated__/TWAP.py new file mode 100644 index 0000000..b23f93b --- /dev/null +++ b/cow_py/codegen/__generated__/TWAP.py @@ -0,0 +1,93 @@ +from hexbytes import HexBytes +from cow_py.common.chains import Chain +from dataclasses import dataclass +from cow_py.codegen.components import ( + BaseMixin, + BaseContract, + FileAbiLoader, + ContractFactory, + get_abi_file, +) + + +@dataclass +class IConditionalOrder_ConditionalOrderParams: + handler: str + salt: HexBytes + staticInput: HexBytes + + +@dataclass +class GPv2Order_Data: + sellToken: str + buyToken: str + receiver: str + sellAmount: int + buyAmount: int + validTo: int + appData: HexBytes + feeAmount: int + kind: HexBytes + partiallyFillable: bool + sellTokenBalance: HexBytes + buyTokenBalance: HexBytes + + +class TWAPMixin(BaseMixin): + def get_tradeable_order( + self, + owner: str, + str_arg_0: str, + ctx: HexBytes, + static_input: HexBytes, + hexbytes_arg_0: HexBytes, + ) -> GPv2Order_Data: + return self.call_contract_method( + "getTradeableOrder", owner, str_arg_0, ctx, static_input, hexbytes_arg_0 + ) + + def supports_interface(self, interface_id: HexBytes) -> bool: + return self.call_contract_method("supportsInterface", interface_id) + + def verify( + self, + owner: str, + sender: str, + _hash: HexBytes, + domain_separator: HexBytes, + ctx: HexBytes, + static_input: HexBytes, + offchain_input: HexBytes, + gpv_2order_data_arg_0: GPv2Order_Data, + ) -> None: + return self.call_contract_method( + "verify", + owner, + sender, + _hash, + domain_separator, + ctx, + static_input, + offchain_input, + ( + gpv_2order_data_arg_0.sellToken, + gpv_2order_data_arg_0.buyToken, + gpv_2order_data_arg_0.receiver, + gpv_2order_data_arg_0.sellAmount, + gpv_2order_data_arg_0.buyAmount, + gpv_2order_data_arg_0.validTo, + gpv_2order_data_arg_0.appData, + gpv_2order_data_arg_0.feeAmount, + gpv_2order_data_arg_0.kind, + gpv_2order_data_arg_0.partiallyFillable, + gpv_2order_data_arg_0.sellTokenBalance, + gpv_2order_data_arg_0.buyTokenBalance, + ), + ) + + +class TWAP(BaseContract, TWAPMixin): + def __init__(self, chain: Chain = Chain.MAINNET, address: str = ""): + abi_loader = FileAbiLoader(get_abi_file("TWAP")) + contract = ContractFactory.create("TWAP", chain, address, abi_loader) + super(TWAP, self).__init__(address, chain, abi=contract.ABI) diff --git a/cow_py/common/constants.py b/cow_py/common/constants.py index 2978a26..9a6bed8 100644 --- a/cow_py/common/constants.py +++ b/cow_py/common/constants.py @@ -8,8 +8,6 @@ the main CoW contract addresses to CoW's supported networks. """ -BUY_ETH_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" - class CowContractAddress(Enum): VAULT_RELAYER = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110" diff --git a/cow_py/contracts/domain.py b/cow_py/contracts/domain.py new file mode 100644 index 0000000..991651d --- /dev/null +++ b/cow_py/contracts/domain.py @@ -0,0 +1,35 @@ +from dataclasses import asdict, dataclass +from typing import Optional + +from cow_py.common.chains import Chain + + +@dataclass +class TypedDataDomain: + name: str + version: str + chainId: int + verifyingContract: str + salt: Optional[str] = None + + def to_dict(self): + base_dict = asdict(self) + if "salt" in base_dict and base_dict["salt"] is None: + del base_dict["salt"] + return base_dict + + +def domain(chain: Chain, verifying_contract: str) -> TypedDataDomain: + """ + Return the Gnosis Protocol v2 domain used for signing. + + :param chain: The EIP-155 chain ID. + :param verifying_contract: The address of the contract that will verify the signature. + :return: An EIP-712 compatible typed domain data. + """ + return TypedDataDomain( + name="Gnosis Protocol", + version="v2", + chainId=chain.chain_id, + verifyingContract=verifying_contract, + ) diff --git a/cow_py/contracts/order.py b/cow_py/contracts/order.py new file mode 100644 index 0000000..c950cd0 --- /dev/null +++ b/cow_py/contracts/order.py @@ -0,0 +1,260 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, Literal, Optional, Union + +from eth_account.messages import _hash_eip191_message, encode_typed_data +from eth_typing import Hash32, HexStr +from eth_utils.conversions import to_bytes, to_hex +from web3.constants import ADDRESS_ZERO + +from cow_py.contracts.domain import TypedDataDomain + + +@dataclass +class Order: + # Sell token address. + sell_token: str = field(metadata={"alias": "sellToken"}) + # Buy token address. + buy_token: str = field(metadata={"alias": "buyToken"}) + # An optional address to receive the proceeds of the trade instead of the + # owner (i.e. the order signer). + receiver: str + # The order sell amount. + # + # For fill or kill sell orders, this amount represents the exact sell amount + # that will be executed in the trade. For fill or kill buy orders, this + # amount represents the maximum sell amount that can be executed. For partial + # fill orders, this represents a component of the limit price fraction. + # + sell_amount: int = field(metadata={"alias": "sellAmount"}) + # The order buy amount. + # + # For fill or kill sell orders, this amount represents the minimum buy amount + # that can be executed in the trade. For fill or kill buy orders, this amount + # represents the exact buy amount that will be executed. For partial fill + # orders, this represents a component of the limit price fraction. + # + buy_amount: int = field(metadata={"alias": "buyAmount"}) + # The timestamp this order is valid until + valid_to: int = field(metadata={"alias": "validTo"}) + # Arbitrary application specific data that can be added to an order. This can + # also be used to ensure uniqueness between two orders with otherwise the + # exact same parameters. + app_data: str = field(metadata={"alias": "appData"}) + # Fee to give to the protocol. + fee_amount: int = field(metadata={"alias": "feeAmount"}) + # The order kind. + kind: str + # Specifies whether or not the order is partially fillable. + partially_fillable: bool = field( + default=False, metadata={"alias": "partiallyFillable"} + ) + # Specifies how the sell token balance will be withdrawn. It can either be + # taken using ERC20 token allowances made directly to the Vault relayer + # (default) or using Balancer Vault internal or external balances. + sell_token_balance: Optional[str] = field( + default=None, metadata={"alias": "sellTokenBalance"} + ) + # Specifies how the buy token balance will be paid. It can either be paid + # directly in ERC20 tokens (default) in Balancer Vault internal balances. + buy_token_balance: Optional[str] = field( + default=None, metadata={"alias": "buyTokenBalance"} + ) + + def __getattr__(self, name): + for f in self.__dataclass_fields__.values(): + if f.metadata.get("alias") == name: + return getattr(self, f.name) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + def __setattr__(self, name, value): + for f in self.__dataclass_fields__.values(): + if f.metadata.get("alias") == name: + return super().__setattr__(f.name, value) + return super().__setattr__(name, value) + + +# Gnosis Protocol v2 order cancellation data. +@dataclass +class OrderCancellations: + order_uids: bytearray + + +BytesLike = Union[str, bytes, HexStr] + +HashLike = Union[BytesLike, int] + + +class OrderKind(Enum): + SELL = "sell" + BUY = "buy" + + +class OrderBalance(Enum): + # Use ERC20 token balances. + ERC20 = "erc20" + # Use Balancer Vault external balances. + + # This can only be specified specified for the sell balance and allows orders + # to re-use Vault ERC20 allowances. When specified for the buy balance, it + # will be treated as {@link OrderBalance.ERC20}. + EXTERNAL = "external" + # Use Balancer Vault internal balances. + INTERNAL = "internal" + + +# /** +# * The EIP-712 type fields definition for a Gnosis Protocol v2 order. +# */ +# The EIP-712 type fields definition for a Gnosis Protocol v2 order. +ORDER_TYPE_FIELDS = [ + dict(name="sellToken", type="address"), + dict(name="buyToken", type="address"), + dict(name="receiver", type="address"), + dict(name="sellAmount", type="uint256"), + dict(name="buyAmount", type="uint256"), + dict(name="validTo", type="uint32"), + dict(name="appData", type="bytes32"), + dict(name="feeAmount", type="uint256"), + dict(name="kind", type="string"), + dict(name="partiallyFillable", type="bool"), + dict(name="sellTokenBalance", type="string"), + dict(name="buyTokenBalance", type="string"), +] +CANCELLATIONS_TYPE_FIELDS = [ + dict(name="orderUids", type="bytes[]"), +] + + +def hashify(h: Union[int, str, bytes]) -> str: + """ + Normalizes an app data value to a 32-byte hash. + :param h: A hash-like value to normalize. Can be an integer, hexadecimal string, or bytes. + :return: A 32-byte hash encoded as a hex-string. + """ + if isinstance(h, int): + # Convert the integer to a hexadecimal string and pad it to 64 characters + return f"0x{h:064x}" + elif isinstance(h, str): + # Convert string to bytes, then pad it to 32 bytes (64 hex characters) + return to_hex(to_bytes(hexstr=h).rjust(32, b"\0")) + elif isinstance(h, bytes): + # Pad the bytes to 32 bytes (64 hex characters) + return to_hex(h.rjust(32, b"\0")) + else: + raise ValueError("Input must be an integer, a hexadecimal string, or bytes.") + + +def normalize_buy_token_balance( + balance: Optional[str], +) -> Literal["erc20", "internal"]: + """ + Normalizes the balance configuration for a buy token. + + :param balance: The balance configuration. + :return: The normalized balance configuration. + """ + if balance in [None, OrderBalance.ERC20.value, OrderBalance.EXTERNAL.value]: + return OrderBalance.ERC20.value + elif balance == OrderBalance.INTERNAL.value: + return OrderBalance.INTERNAL.value + else: + raise ValueError(f"Invalid order balance {balance}") + + +def normalize_order(order: Order) -> Dict[str, Union[str, int]]: + if order.receiver == ADDRESS_ZERO: + raise ValueError("receiver cannot be address(0)") + + return { + "sellToken": order.sell_token, + "buyToken": order.buy_token, + "receiver": order.receiver if order.receiver else ADDRESS_ZERO, + "sellAmount": order.sell_amount, + "buyAmount": order.buy_amount, + "validTo": order.valid_to, + "appData": hashify(order.app_data), + "feeAmount": order.fee_amount, + "kind": order.kind, + "partiallyFillable": order.partially_fillable, + "sellTokenBalance": ( + order.sell_token_balance + if order.sell_token_balance + else OrderBalance.ERC20.value + ), + "buyTokenBalance": normalize_buy_token_balance(order.buy_token_balance), + } + + +def hash_typed_data( + domain: TypedDataDomain, types: Dict[str, Any], data: Dict[str, Any] +) -> Hash32: + """ + Compute the 32-byte signing hash for the specified order. + + :param domain: The EIP-712 domain separator to compute the hash for. + :param types: The typed data types. + :param data: The data to compute the digest for. + :return: Hex-encoded 32-byte order digest. + """ + encoded_data = encode_typed_data( + domain_data=domain.to_dict(), message_types=types, message_data=data + ) + return _hash_eip191_message(encoded_data) + + +def hash_order(domain: TypedDataDomain, order: Order) -> Hash32: + """ + Compute the 32-byte signing hash for the specified order. + + :param domain: The EIP-712 domain separator to compute the hash for. + :param order: The order to compute the digest for. + :return: Hex-encoded 32-byte order digest. + """ + return hash_typed_data(domain, {"Order": ORDER_TYPE_FIELDS}, normalize_order(order)) + + +def hash_order_cancellation(domain: TypedDataDomain, order_uid: str) -> str: + """ + Compute the 32-byte signing hash for the specified cancellation. + + :param domain: The EIP-712 domain separator to compute the hash for. + :param order_uid: The unique identifier of the order to cancel. + :return: Hex-encoded 32-byte order digest. + """ + return hash_order_cancellations(domain, [order_uid]) + + +def hash_order_cancellations( + domain_data: TypedDataDomain, order_uids: list[str] +) -> str: + """ + Compute the 32-byte signing hash for the specified order cancellations. + + :param domain_data: The EIP-712 domain separator to compute the hash for. + :param order_uids: The unique identifiers of the orders to cancel. + :return: Hex-encoded 32-byte order digest. + """ + return _hash_eip191_message( + encode_typed_data( + domain_data.to_dict(), + message_types={"OrderCancellations": CANCELLATIONS_TYPE_FIELDS}, + message_data={"orderUids": order_uids}, + ) + ).hex() + + +# The byte length of an order UID. +ORDER_UID_LENGTH = 56 + + +@dataclass +class OrderUidParams: + # The EIP-712 order struct hash. + order_digest: str = field(metadata={"alias": "orderDigest"}) + # The owner of the order. + owner: str + # The timestamp this order is valid until. + validTo: int diff --git a/cow_py/contracts/sign.py b/cow_py/contracts/sign.py new file mode 100644 index 0000000..41a2548 --- /dev/null +++ b/cow_py/contracts/sign.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from enum import IntEnum +from typing import Any, Dict, List, Union + +from eth_account import Account +from eth_account.datastructures import SignedMessage +from eth_account.signers.local import LocalAccount +from eth_typing import ChecksumAddress +from eth_utils.conversions import to_hex +from eth_utils.crypto import keccak +from web3 import Web3 + +from cow_py.contracts.domain import TypedDataDomain +from cow_py.contracts.order import ( + CANCELLATIONS_TYPE_FIELDS, + ORDER_TYPE_FIELDS, + Order, + hash_typed_data, + normalize_order, +) + +EIP1271_MAGICVALUE = to_hex(keccak(text="isValidSignature(bytes32,bytes)"))[:10] +PRE_SIGNED = to_hex(keccak(text="GPv2Signing.Scheme.PreSign")) + + +class SigningScheme(IntEnum): + # The EIP-712 typed data signing scheme. This is the preferred scheme as it + # provides more infomation to wallets performing the signature on the data + # being signed. + # + # https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md#definition-of-domainseparator + EIP712 = 0b00 + # Message signed using eth_sign RPC call. + ETHSIGN = 0b01 + # Smart contract signatures as defined in EIP-1271. + EIP1271 = 0b10 + # Pre-signed order. + PRESIGN = 0b11 + + +@dataclass +class EcdsaSignature: + scheme: SigningScheme + data: str + + +@dataclass +class Eip1271SignatureData: + verifier: str + signature: bytes + + +@dataclass +class Eip1271Signature: + scheme: SigningScheme + data: Eip1271SignatureData + + +@dataclass +class PreSignSignature: + scheme: SigningScheme + data: str + + +Signature = Union[EcdsaSignature, Eip1271Signature, PreSignSignature] + + +def ecdsa_sign_typed_data( + owner: LocalAccount, + domain_data: TypedDataDomain, + message_types: Dict[str, Any], + message_data: Dict[str, Any], +) -> SignedMessage: + return Account._sign_hash( + hash_typed_data(domain_data, message_types, message_data), owner.key + ) + + +def sign_order( + domain: TypedDataDomain, order: Order, owner: LocalAccount, scheme: SigningScheme +) -> EcdsaSignature: + normalized_order = normalize_order(order) + signed_data = ecdsa_sign_typed_data( + owner, domain, {"Order": ORDER_TYPE_FIELDS}, normalized_order + ) + return EcdsaSignature( + scheme=scheme, + data=signed_data.signature.hex(), + ) + + +def sign_order_cancellation( + domain: TypedDataDomain, + order_uid: Union[str, bytes], + owner: LocalAccount, + scheme: SigningScheme, +): + return sign_order_cancellations(domain, [order_uid], owner, scheme) + + +def sign_order_cancellations( + domain: TypedDataDomain, + order_uids: List[Union[str, bytes]], + owner: LocalAccount, + scheme: SigningScheme, +): + data = {"orderUids": order_uids} + types = {"OrderCancellations": CANCELLATIONS_TYPE_FIELDS} + + signed_data = ecdsa_sign_typed_data(owner, domain, types, data) + + return EcdsaSignature(scheme=scheme, data=signed_data.signature.hex()) + + +def encode_eip1271_signature_data(verifier: ChecksumAddress, signature: str) -> bytes: + return Web3.solidity_keccak(["address", "bytes"], [verifier, signature]) + + +def decode_eip1271_signature_data(signature: str) -> Eip1271SignatureData: + arrayified_signature = bytes.fromhex(signature[2:]) # Removing '0x' + verifier = Web3.to_checksum_address(arrayified_signature[:20].hex()) + return Eip1271SignatureData(verifier, arrayified_signature[20:]) diff --git a/cow_py/order_book/api.py b/cow_py/order_book/api.py new file mode 100644 index 0000000..c99b394 --- /dev/null +++ b/cow_py/order_book/api.py @@ -0,0 +1,203 @@ +import json +from typing import Any, Dict, List + +from cow_py.common.api.api_base import ApiBase, Context +from cow_py.common.config import SupportedChainId +from cow_py.order_book.config import OrderBookAPIConfigFactory +from typing import Union +from cow_py.order_book.generated.model import OrderQuoteSide2, OrderQuoteValidity2 + +from .generated.model import ( + UID, + Address, + AppDataHash, + AppDataObject, + NativePriceResponse, + Order, + OrderCancellation, + OrderCreation, + OrderQuoteRequest, + OrderQuoteResponse, + OrderQuoteSide, + OrderQuoteSide1, + OrderQuoteSide3, + OrderQuoteValidity, + OrderQuoteValidity1, + SolverCompetitionResponse, + TotalSurplus, + Trade, + TransactionHash, +) + + +class OrderBookApi(ApiBase): + def __init__( + self, + config=OrderBookAPIConfigFactory.get_config("prod", SupportedChainId.MAINNET), + ): + self.config = config + + async def get_version(self, context_override: Context = {}) -> str: + return await self._fetch( + path="/api/v1/version", context_override=context_override + ) + + async def get_trades_by_owner( + self, owner: Address, context_override: Context = {} + ) -> List[Trade]: + response = await self._fetch( + path="/api/v1/trades", + params={"owner": owner}, + context_override=context_override, + ) + return [Trade(**trade) for trade in response] + + async def get_trades_by_order_uid( + self, order_uid: UID, context_override: Context = {} + ) -> List[Trade]: + response = await self._fetch( + path="/api/v1/trades", + params={"order_uid": order_uid}, + context_override=context_override, + ) + return [Trade(**trade) for trade in response] + + async def get_orders_by_owner( + self, + owner: Address, + limit: int = 1000, + offset: int = 0, + context_override: Context = {}, + ) -> List[Order]: + return [ + Order(**order) + for order in await self._fetch( + path=f"/api/v1/account/{owner}/orders", + params={"limit": limit, "offset": offset}, + context_override=context_override, + ) + ] + + async def get_order_by_uid( + self, order_uid: UID, context_override: Context = {} + ) -> Order: + response = await self._fetch( + path=f"/api/v1/orders/{order_uid}", + context_override=context_override, + ) + return Order(**response) + + def get_order_link(self, order_uid: UID) -> str: + return self.config.get_base_url() + f"/api/v1/orders/{order_uid.root}" + + async def get_tx_orders( + self, tx_hash: TransactionHash, context_override: Context = {} + ) -> List[Order]: + response = await self._fetch( + path=f"/api/v1/transactions/{tx_hash}/orders", + context_override=context_override, + ) + return [Order(**order) for order in response] + + async def get_native_price( + self, tokenAddress: Address, context_override: Context = {} + ) -> NativePriceResponse: + response = await self._fetch( + path=f"/api/v1/token/{tokenAddress}/native_price", + context_override=context_override, + ) + return NativePriceResponse(**response) + + async def get_total_surplus( + self, user: Address, context_override: Context = {} + ) -> TotalSurplus: + response = await self._fetch( + path=f"/api/v1/users/{user}/total_surplus", + context_override=context_override, + ) + return TotalSurplus(**response) + + async def get_app_data( + self, app_data_hash: AppDataHash, context_override: Context = {} + ) -> Dict[str, Any]: + return await self._fetch( + path=f"/api/v1/app_data/{app_data_hash}", + context_override=context_override, + ) + + async def get_solver_competition( + self, action_id: Union[int, str] = "latest", context_override: Context = {} + ) -> SolverCompetitionResponse: + response = await self._fetch( + path=f"/api/v1/solver_competition/{action_id}", + context_override=context_override, + ) + return SolverCompetitionResponse(**response) + + async def get_solver_competition_by_tx_hash( + self, tx_hash: TransactionHash, context_override: Context = {} + ) -> SolverCompetitionResponse: + response = await self._fetch( + path=f"/api/v1/solver_competition/by_tx_hash/{tx_hash}", + context_override=context_override, + ) + return SolverCompetitionResponse(**response) + + async def post_quote( + self, + request: OrderQuoteRequest, + side: Union[OrderQuoteSide, OrderQuoteSide1, OrderQuoteSide2, OrderQuoteSide3], + validity: Union[ + OrderQuoteValidity, OrderQuoteValidity1, OrderQuoteValidity2 + ] = OrderQuoteValidity1(validTo=None), + context_override: Context = {}, + ) -> OrderQuoteResponse: + response = await self._fetch( + path="/api/v1/quote", + json={ + **request.model_dump(by_alias=True), + # side object need to be converted to json first to avoid on kind type + **json.loads(side.model_dump_json()), + **validity.model_dump(), + }, + context_override=context_override, + method="POST", + ) + return OrderQuoteResponse(**response) + + async def post_order(self, order: OrderCreation, context_override: Context = {}): + response = await self._fetch( + path="/api/v1/orders", + json=json.loads(order.model_dump_json(by_alias=True)), + context_override=context_override, + method="POST", + ) + return UID(response) + + async def delete_order( + self, + orders_cancelation: OrderCancellation, + context_override: Context = {}, + ): + response = await self._fetch( + path="/api/v1/orders", + json=orders_cancelation.model_dump_json(), + context_override=context_override, + method="DELETE", + ) + return UID(response) + + async def put_app_data( + self, + app_data: AppDataObject, + app_data_hash: str = "", + context_override: Context = {}, + ) -> AppDataHash: + app_data_hash_url = app_data_hash if app_data_hash else "" + response = await self._fetch( + path=f"/api/v1/app_data/{app_data_hash_url}", + json=app_data.model_dump_json(), + context_override=context_override, + method="PUT", + ) + return AppDataHash(response) diff --git a/cow_py/order_book/config.py b/cow_py/order_book/config.py new file mode 100644 index 0000000..1953ca7 --- /dev/null +++ b/cow_py/order_book/config.py @@ -0,0 +1,39 @@ +from typing import Dict, Literal, Type + +from cow_py.common.api.api_base import APIConfig +from cow_py.common.config import SupportedChainId + + +class ProdAPIConfig(APIConfig): + config_map = { + SupportedChainId.MAINNET: "https://api.cow.fi/mainnet", + SupportedChainId.GNOSIS_CHAIN: "https://api.cow.fi/xdai", + SupportedChainId.SEPOLIA: "https://api.cow.fi/sepolia", + } + + +class StagingAPIConfig(APIConfig): + config_map = { + SupportedChainId.MAINNET: "https://barn.api.cow.fi/mainnet", + SupportedChainId.GNOSIS_CHAIN: "https://barn.api.cow.fi/xdai", + SupportedChainId.SEPOLIA: "https://barn.api.cow.fi/sepolia", + } + + +Envs = Literal["prod", "staging"] + + +class OrderBookAPIConfigFactory: + config_classes: Dict[Envs, Type[APIConfig]] = { + "prod": ProdAPIConfig, + "staging": StagingAPIConfig, + } + + @staticmethod + def get_config(env: Envs, chain_id: SupportedChainId) -> APIConfig: + config_class = OrderBookAPIConfigFactory.config_classes.get(env) + + if config_class: + return config_class(chain_id) + else: + raise ValueError("Unknown environment") diff --git a/cow_py/order_book/generated/model.py b/cow_py/order_book/generated/model.py new file mode 100644 index 0000000..ccdc9b1 --- /dev/null +++ b/cow_py/order_book/generated/model.py @@ -0,0 +1,711 @@ +# generated by datamodel-codegen: +# filename: https://raw.githubusercontent.com/cowprotocol/services/v2.245.1/crates/orderbook/openapi.yml +# timestamp: 2024-09-05T14:36:27+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel, Field, RootModel + + +class TransactionHash(RootModel[str]): + root: str = Field( + ..., + description="32 byte digest encoded as a hex with `0x` prefix.", + examples=["0xd51f28edffcaaa76be4a22f6375ad289272c037f3cc072345676e88d92ced8b5"], + ) + + +class Address(RootModel[str]): + root: str = Field( + ..., + description="20 byte Ethereum address encoded as a hex with `0x` prefix.", + examples=["0x6810e776880c02933d47db1b9fc05908e5386b96"], + ) + + +class AppData(RootModel[str]): + root: str = Field( + ..., + description="The string encoding of a JSON object representing some `appData`. The\nformat of the JSON expected in the `appData` field is defined\n[here](https://github.com/cowprotocol/app-data).\n", + examples=['{"version":"0.9.0","metadata":{}}'], + ) + + +class AppDataHash(RootModel[str]): + root: str = Field( + ..., + description="32 bytes encoded as hex with `0x` prefix.\nIt's expected to be the hash of the stringified JSON object representing the `appData`.\n", + examples=["0x0000000000000000000000000000000000000000000000000000000000000000"], + ) + + +class AppDataObject(BaseModel): + fullAppData: Optional[AppData] = None + + +class BigUint(RootModel[str]): + root: str = Field( + ..., + description="A big unsigned integer encoded in decimal.", + examples=["1234567890"], + ) + + +class CallData(RootModel[str]): + root: str = Field( + ..., + description="Some `calldata` sent to a contract in a transaction encoded as a hex with `0x` prefix.", + examples=["0xca11da7a"], + ) + + +class TokenAmount(RootModel[str]): + root: str = Field( + ..., + description="Amount of a token. `uint256` encoded in decimal.", + examples=["1234567890"], + ) + + +class PlacementError(Enum): + QuoteNotFound = "QuoteNotFound" + ValidToTooFarInFuture = "ValidToTooFarInFuture" + PreValidationError = "PreValidationError" + + +class OnchainOrderData(BaseModel): + sender: Address = Field( + ..., + description="If orders are placed as on-chain orders, the owner of the order might\nbe a smart contract, but not the user placing the order. The\nactual user will be provided in this field.\n", + ) + placementError: Optional[PlacementError] = Field( + None, + description="Describes the error, if the order placement was not successful. This could\nhappen, for example, if the `validTo` is too high, or no valid quote was\nfound or generated.\n", + ) + + +class EthflowData(BaseModel): + refundTxHash: TransactionHash = Field( + ..., + description="Specifies in which transaction the order was refunded. If\nthis field is null the order was not yet refunded.\n", + ) + userValidTo: int = Field( + ..., + description="Describes the `validTo` of an order ethflow order.\n\n**NOTE**: For ethflow orders, the `validTo` encoded in the smart\ncontract is `type(uint256).max`.\n", + ) + + +class OrderKind(Enum): + buy = "buy" + sell = "sell" + + +class OrderClass(Enum): + market = "market" + limit = "limit" + liquidity = "liquidity" + + +class SellTokenSource(Enum): + erc20 = "erc20" + internal = "internal" + external = "external" + + +class BuyTokenDestination(Enum): + erc20 = "erc20" + internal = "internal" + + +class PriceQuality(Enum): + fast = "fast" + optimal = "optimal" + verified = "verified" + + +class OrderStatus(Enum): + presignaturePending = "presignaturePending" + open = "open" + fulfilled = "fulfilled" + cancelled = "cancelled" + expired = "expired" + + +class ProtocolAppData(BaseModel): + pass + + +class AuctionPrices(RootModel[Optional[Dict[str, BigUint]]]): + root: Optional[Dict[str, BigUint]] = None + + +class UID(RootModel[str]): + root: str = Field( + ..., + description="Unique identifier for the order: 56 bytes encoded as hex with `0x` prefix.\nBytes 0..32 are the order digest, bytes 30..52 the owner address and bytes\n52..56 the expiry (`validTo`) as a `uint32` unix epoch timestamp.\n", + examples=[ + "0xff2e2e54d178997f173266817c1e9ed6fee1a1aae4b43971c53b543cffcc2969845c6f5599fbb25dbdd1b9b013daf85c03f3c63763e4bc4a" + ], + ) + + +class SigningScheme(Enum): + eip712 = "eip712" + ethsign = "ethsign" + presign = "presign" + eip1271 = "eip1271" + + +class EcdsaSigningScheme(Enum): + eip712 = "eip712" + ethsign = "ethsign" + + +class EcdsaSignature(RootModel[str]): + root: str = Field( + ..., + description="65 bytes encoded as hex with `0x` prefix. `r || s || v` from the spec.", + examples=[ + "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ], + ) + + +class PreSignature(RootModel[str]): + root: str = Field( + ..., + description='Empty signature bytes. Used for "presign" signatures.', + examples=["0x"], + ) + + +class ErrorType(Enum): + DuplicatedOrder = "DuplicatedOrder" + QuoteNotFound = "QuoteNotFound" + InvalidQuote = "InvalidQuote" + MissingFrom = "MissingFrom" + WrongOwner = "WrongOwner" + InvalidEip1271Signature = "InvalidEip1271Signature" + InsufficientBalance = "InsufficientBalance" + InsufficientAllowance = "InsufficientAllowance" + InvalidSignature = "InvalidSignature" + InsufficientFee = "InsufficientFee" + SellAmountOverflow = "SellAmountOverflow" + TransferSimulationFailed = "TransferSimulationFailed" + ZeroAmount = "ZeroAmount" + IncompatibleSigningScheme = "IncompatibleSigningScheme" + TooManyLimitOrders_UnsupportedBuyTokenDestination = ( + "TooManyLimitOrders UnsupportedBuyTokenDestination" + ) + UnsupportedSellTokenSource = "UnsupportedSellTokenSource" + UnsupportedOrderType = "UnsupportedOrderType" + InsufficientValidTo = "InsufficientValidTo" + ExcessiveValidTo = "ExcessiveValidTo" + InvalidNativeSellToken = "InvalidNativeSellToken" + SameBuyAndSellToken = "SameBuyAndSellToken" + UnsupportedToken = "UnsupportedToken" + InvalidAppData = "InvalidAppData" + AppDataHashMismatch = "AppDataHashMismatch" + AppdataFromMismatch = "AppdataFromMismatch" + + +class OrderPostError(BaseModel): + errorType: ErrorType + description: str + + +class ErrorType1(Enum): + InvalidSignature = "InvalidSignature" + WrongOwner = "WrongOwner" + OrderNotFound = "OrderNotFound" + AlreadyCancelled = "AlreadyCancelled" + OrderFullyExecuted = "OrderFullyExecuted" + OrderExpired = "OrderExpired" + OnChainOrder = "OnChainOrder" + + +class OrderCancellationError(BaseModel): + errorType: ErrorType1 + description: str + + +class ErrorType2(Enum): + AlreadyCancelled = "AlreadyCancelled" + OrderFullyExecuted = "OrderFullyExecuted" + OrderExpired = "OrderExpired" + OnChainOrder = "OnChainOrder" + DuplicatedOrder = "DuplicatedOrder" + InsufficientFee = "InsufficientFee" + InsufficientAllowance = "InsufficientAllowance" + InsufficientBalance = "InsufficientBalance" + InsufficientValidTo = "InsufficientValidTo" + ExcessiveValidTo = "ExcessiveValidTo" + InvalidSignature = "InvalidSignature" + TransferSimulationFailed = "TransferSimulationFailed" + UnsupportedToken = "UnsupportedToken" + WrongOwner = "WrongOwner" + SameBuyAndSellToken = "SameBuyAndSellToken" + ZeroAmount = "ZeroAmount" + UnsupportedBuyTokenDestination = "UnsupportedBuyTokenDestination" + UnsupportedSellTokenSource = "UnsupportedSellTokenSource" + UnsupportedOrderType = "UnsupportedOrderType" + + +class ReplaceOrderError(BaseModel): + errorType: ErrorType2 + description: str + + +class ErrorType3(Enum): + UnsupportedToken = "UnsupportedToken" + ZeroAmount = "ZeroAmount" + UnsupportedOrderType = "UnsupportedOrderType" + + +class PriceEstimationError(BaseModel): + errorType: ErrorType3 + description: str + + +class OrderQuoteSideKindSell(Enum): + sell = "sell" + + +class OrderQuoteSideKindBuy(Enum): + buy = "buy" + + +class OrderQuoteValidity1(BaseModel): + validTo: Optional[int] = Field( + None, description="Unix timestamp (`uint32`) until which the order is valid." + ) + + +class OrderQuoteValidity2(BaseModel): + validFor: Optional[int] = Field( + None, + description="Number (`uint32`) of seconds that the order should be valid for.", + ) + + +class OrderQuoteValidity(RootModel[Union[OrderQuoteValidity1, OrderQuoteValidity2]]): + root: Union[OrderQuoteValidity1, OrderQuoteValidity2] = Field( + ..., description="The validity for the order." + ) + + +class Objective(BaseModel): + total: Optional[float] = Field( + None, description="The total objective value used for ranking solutions." + ) + surplus: Optional[float] = None + fees: Optional[float] = None + cost: Optional[float] = None + gas: Optional[int] = None + + +class Order1(BaseModel): + id: Optional[UID] = None + executedAmount: Optional[BigUint] = None + + +class SolverSettlement(BaseModel): + solver: Optional[str] = Field(None, description="Name of the solver.") + solverAddress: Optional[str] = Field( + None, + description="The address used by the solver to execute the settlement on-chain.\nThis field is missing for old settlements, the zero address has been used instead.\n", + ) + objective: Optional[Objective] = None + score: Optional[BigUint] = Field( + None, + description="The score of the current auction as defined in [CIP-20](https://snapshot.org/#/cow.eth/proposal/0x2d3f9bd1ea72dca84b03e97dda3efc1f4a42a772c54bd2037e8b62e7d09a491f).\nIt is `null` for old auctions.\n", + ) + clearingPrices: Optional[Dict[str, BigUint]] = Field( + None, + description="The prices of tokens for settled user orders as passed to the settlement contract.\n", + ) + orders: Optional[List[Order1]] = Field(None, description="Touched orders.") + callData: Optional[CallData] = Field( + None, + description="Transaction `calldata` that is executed on-chain if the settlement is executed.", + ) + uninternalizedCallData: Optional[CallData] = Field( + None, + description="Full `calldata` as generated from the original solver output.\n\nIt can be different from the executed transaction if part of the settlements are internalised\n(use internal liquidity in lieu of trading against on-chain liquidity).\n\nThis field is omitted in case it coincides with `callData`.\n", + ) + + +class NativePriceResponse(BaseModel): + price: Optional[float] = Field(None, description="Estimated price of the token.") + + +class TotalSurplus(BaseModel): + totalSurplus: Optional[str] = Field(None, description="The total surplus.") + + +class InteractionData(BaseModel): + target: Optional[Address] = None + value: Optional[TokenAmount] = None + call_data: Optional[List[CallData]] = Field( + None, description="The call data to be used for the interaction." + ) + + +class Surplus(BaseModel): + factor: float + max_volume_factor: float + + +class Volume(BaseModel): + factor: float + + +class FeePolicy(RootModel[Union[Surplus, Volume]]): + root: Union[Surplus, Volume] = Field( + ..., description="Defines the ways to calculate the protocol fee." + ) + + +class OrderParameters(BaseModel): + sellToken: Address = Field(..., description="ERC-20 token to be sold.") + buyToken: Address = Field(..., description="ERC-20 token to be bought.") + receiver: Optional[Address] = Field( + None, + description="An optional Ethereum address to receive the proceeds of the trade instead\nof the owner (i.e. the order signer).\n", + ) + sellAmount: TokenAmount = Field( + ..., description="Amount of `sellToken` to be sold in atoms." + ) + buyAmount: TokenAmount = Field( + ..., description="Amount of `buyToken` to be bought in atoms." + ) + validTo: int = Field( + ..., description="Unix timestamp (`uint32`) until which the order is valid." + ) + appData: AppDataHash + feeAmount: TokenAmount = Field( + ..., description="feeRatio * sellAmount + minimal_fee in atoms." + ) + kind: OrderKind = Field(..., description="The kind is either a buy or sell order.") + partiallyFillable: bool = Field( + ..., description="Is the order fill-or-kill or partially fillable?" + ) + sellTokenBalance: Optional[SellTokenSource] = "erc20" + buyTokenBalance: Optional[BuyTokenDestination] = "erc20" + signingScheme: Optional[SigningScheme] = "eip712" + + +class OrderMetaData(BaseModel): + creationDate: str = Field( + ..., + description="Creation time of the order. Encoded as ISO 8601 UTC.", + examples=["2020-12-03T18:35:18.814523Z"], + ) + class_: OrderClass = Field(..., alias="class") + owner: Address + uid: UID + availableBalance: Optional[TokenAmount] = Field( + None, + description="Unused field that is currently always set to `null` and will be removed in the future.\n", + ) + executedSellAmount: BigUint = Field( + ..., + description="The total amount of `sellToken` that has been executed for this order including fees.\n", + ) + executedSellAmountBeforeFees: BigUint = Field( + ..., + description="The total amount of `sellToken` that has been executed for this order without fees.\n", + ) + executedBuyAmount: BigUint = Field( + ..., + description="The total amount of `buyToken` that has been executed for this order.\n", + ) + executedFeeAmount: BigUint = Field( + ..., + description="The total amount of fees that have been executed for this order.", + ) + invalidated: bool = Field(..., description="Has this order been invalidated?") + status: OrderStatus = Field(..., description="Order status.") + fullFeeAmount: Optional[TokenAmount] = Field( + None, description="Amount that the signed fee would be without subsidies." + ) + isLiquidityOrder: Optional[bool] = Field( + None, + description="Liquidity orders are functionally the same as normal smart contract orders but are not\nplaced with the intent of actively getting traded. Instead they facilitate the\ntrade of normal orders by allowing them to be matched against liquidity orders which\nuses less gas and can have better prices than external liquidity.\n\nAs such liquidity orders will only be used in order to improve settlement of normal\norders. They should not be expected to be traded otherwise and should not expect to get\nsurplus.\n", + ) + ethflowData: Optional[EthflowData] = None + onchainUser: Optional[Address] = Field( + None, + description="This represents the actual trader of an on-chain order.\n\n### ethflow orders\n\nIn this case, the `owner` would be the `EthFlow` contract and *not* the actual trader.\n", + ) + onchainOrderData: Optional[OnchainOrderData] = Field( + None, + description="There is some data only available for orders that are placed on-chain. This data\ncan be found in this object.\n", + ) + executedSurplusFee: Optional[BigUint] = Field( + None, description="Surplus fee that the limit order was executed with." + ) + fullAppData: Optional[str] = Field( + None, + description="Full `appData`, which the contract-level `appData` is a hash of. See `OrderCreation`\nfor more information.\n", + ) + + +class CompetitionAuction(BaseModel): + orders: Optional[List[UID]] = Field( + None, description="The UIDs of the orders included in the auction.\n" + ) + prices: Optional[AuctionPrices] = None + + +class OrderCancellations(BaseModel): + orderUids: Optional[List[UID]] = Field( + None, description="UIDs of orders to cancel." + ) + signature: EcdsaSignature = Field( + ..., description="`OrderCancellation` signed by the owner." + ) + signingScheme: EcdsaSigningScheme + + +class OrderCancellation(BaseModel): + signature: EcdsaSignature = Field( + ..., description="OrderCancellation signed by owner" + ) + signingScheme: EcdsaSigningScheme + + +class Trade(BaseModel): + blockNumber: int = Field(..., description="Block in which trade occurred.") + logIndex: int = Field( + ..., description="Index in which transaction was included in block." + ) + orderUid: UID = Field(..., description="UID of the order matched by this trade.") + owner: Address = Field(..., description="Address of trader.") + sellToken: Address = Field(..., description="Address of token sold.") + buyToken: Address = Field(..., description="Address of token bought.") + sellAmount: TokenAmount = Field( + ..., + description="Total amount of `sellToken` that has been executed for this trade (including fees).", + ) + sellAmountBeforeFees: BigUint = Field( + ..., + description="The total amount of `sellToken` that has been executed for this order without fees.", + ) + buyAmount: TokenAmount = Field( + ..., description="Total amount of `buyToken` received in this trade." + ) + txHash: TransactionHash = Field( + ..., + description="Transaction hash of the corresponding settlement transaction containing the trade (if available).", + ) + + +class Signature(RootModel[Union[EcdsaSignature, PreSignature]]): + root: Union[EcdsaSignature, PreSignature] = Field(..., description="A signature.") + + +class OrderQuoteSide1(BaseModel): + kind: OrderQuoteSideKindSell + sellAmountBeforeFee: TokenAmount = Field( + ..., + description="The total amount that is available for the order. From this value, the fee\nis deducted and the buy amount is calculated.\n", + ) + + +class OrderQuoteSide2(BaseModel): + kind: OrderQuoteSideKindSell + sellAmountAfterFee: TokenAmount = Field( + ..., description="The `sellAmount` for the order." + ) + + +class OrderQuoteSide3(BaseModel): + kind: OrderQuoteSideKindBuy + buyAmountAfterFee: TokenAmount = Field( + ..., description="The `buyAmount` for the order." + ) + + +class OrderQuoteSide( + RootModel[Union[OrderQuoteSide1, OrderQuoteSide2, OrderQuoteSide3]] +): + root: Union[OrderQuoteSide1, OrderQuoteSide2, OrderQuoteSide3] = Field( + ..., description="The buy or sell side when quoting an order." + ) + + +class OrderQuoteRequest(BaseModel): + sellToken: Address = Field(..., description="ERC-20 token to be sold") + buyToken: Address = Field(..., description="ERC-20 token to be bought") + receiver: Optional[Address] = Field( + None, + description="An optional address to receive the proceeds of the trade instead of the\n`owner` (i.e. the order signer).\n", + ) + appData: Optional[Union[AppData, AppDataHash]] = Field( + None, + description="AppData which will be assigned to the order.\nExpects either a string JSON doc as defined on [AppData](https://github.com/cowprotocol/app-data) or a\nhex encoded string for backwards compatibility.\nWhen the first format is used, it's possible to provide the derived appDataHash field.\n", + ) + appDataHash: Optional[AppDataHash] = Field( + None, + description="The hash of the stringified JSON appData doc.\nIf present, `appData` field must be set with the aforementioned data where this hash is derived from.\nIn case they differ, the call will fail.\n", + ) + sellTokenBalance: Optional[SellTokenSource] = "erc20" + buyTokenBalance: Optional[BuyTokenDestination] = "erc20" + from_: Address = Field(..., alias="from") + priceQuality: Optional[PriceQuality] = "verified" + signingScheme: Optional[SigningScheme] = "eip712" + onchainOrder: Optional[Any] = Field( + False, + description='Flag to signal whether the order is intended for on-chain order placement. Only valid\nfor non ECDSA-signed orders."\n', + ) + + +class OrderQuoteResponse(BaseModel): + quote: OrderParameters + from_: Optional[Address] = Field(None, alias="from") + expiration: str = Field( + ..., + description="Expiration date of the offered fee. Order service might not accept\nthe fee after this expiration date. Encoded as ISO 8601 UTC.\n", + examples=["1985-03-10T18:35:18.814523Z"], + ) + id: Optional[int] = Field( + None, + description="Quote ID linked to a quote to enable providing more metadata when analysing\norder slippage.\n", + ) + verified: bool = Field( + ..., + description="Whether it was possible to verify that the quoted amounts are accurate using a simulation.\n", + ) + + +class SolverCompetitionResponse(BaseModel): + auctionId: Optional[int] = Field( + None, description="The ID of the auction the competition info is for." + ) + transactionHash: Optional[TransactionHash] = Field( + None, + description="The hash of the transaction that the winning solution of this info was submitted in.", + ) + gasPrice: Optional[float] = Field( + None, description="Gas price used for ranking solutions." + ) + liquidityCollectedBlock: Optional[int] = None + competitionSimulationBlock: Optional[int] = None + auction: Optional[CompetitionAuction] = None + solutions: Optional[List[SolverSettlement]] = Field( + None, + description="Maps from solver name to object describing that solver's settlement.", + ) + + +class OrderCreation(BaseModel): + sellToken: Address = Field(..., description="see `OrderParameters::sellToken`") + buyToken: Address = Field(..., description="see `OrderParameters::buyToken`") + receiver: Optional[Address] = Field( + None, description="see `OrderParameters::receiver`" + ) + sellAmount: TokenAmount = Field( + ..., description="see `OrderParameters::sellAmount`" + ) + buyAmount: TokenAmount = Field(..., description="see `OrderParameters::buyAmount`") + validTo: int = Field(..., description="see `OrderParameters::validTo`") + feeAmount: TokenAmount = Field(..., description="see `OrderParameters::feeAmount`") + kind: OrderKind = Field(..., description="see `OrderParameters::kind`") + partiallyFillable: bool = Field( + ..., description="see `OrderParameters::partiallyFillable`" + ) + sellTokenBalance: Optional[SellTokenSource] = Field( + "erc20", description="see `OrderParameters::sellTokenBalance`" + ) + buyTokenBalance: Optional[BuyTokenDestination] = Field( + "erc20", description="see `OrderParameters::buyTokenBalance`" + ) + signingScheme: SigningScheme + signature: Signature + from_: Optional[Address] = Field( + None, + alias="from", + description="If set, the backend enforces that this address matches what is decoded as the *signer* of\nthe signature. This helps catch errors with invalid signature encodings as the backend\nmight otherwise silently work with an unexpected address that for example does not have\nany balance.\n", + ) + quoteId: Optional[int] = Field( + None, + description="Orders can optionally include a quote ID. This way the order can be linked to a quote\nand enable providing more metadata when analysing order slippage.\n", + ) + appData: Union[AppData, AppDataHash] = Field( + ..., + description="This field comes in two forms for backward compatibility. The hash form will eventually \nstop being accepted.\n", + ) + appDataHash: Optional[AppDataHash] = Field( + None, + description="May be set for debugging purposes. If set, this field is compared to what the backend\ninternally calculates as the app data hash based on the contents of `appData`. If the\nhash does not match, an error is returned. If this field is set, then `appData` **MUST** be\na string encoding of a JSON object.\n", + ) + + +class Order(OrderCreation, OrderMetaData): + pass + + +class AuctionOrder(BaseModel): + uid: UID + sellToken: Address = Field(..., description="see `OrderParameters::sellToken`") + buyToken: Address = Field(..., description="see `OrderParameters::buyToken`") + sellAmount: TokenAmount = Field( + ..., description="see `OrderParameters::sellAmount`" + ) + buyAmount: TokenAmount = Field(..., description="see `OrderParameters::buyAmount`") + userFee: TokenAmount = Field(..., description="see `OrderParameters::feeAmount`") + validTo: int = Field(..., description="see `OrderParameters::validTo`") + kind: OrderKind = Field(..., description="see `OrderParameters::kind`") + receiver: Address = Field(..., description="see `OrderParameters::receiver`") + owner: Address + partiallyFillable: bool = Field( + ..., description="see `OrderParameters::partiallyFillable`" + ) + executed: TokenAmount = Field( + ..., + description="Currently executed amount of sell/buy token, depending on the order kind.\n", + ) + preInteractions: List[InteractionData] = Field( + ..., + description="The pre-interactions that need to be executed before the first execution of the order.\n", + ) + postInteractions: List[InteractionData] = Field( + ..., + description="The post-interactions that need to be executed after the execution of the order.\n", + ) + sellTokenBalance: SellTokenSource = Field( + ..., description="see `OrderParameters::sellTokenBalance`" + ) + buyTokenBalance: BuyTokenDestination = Field( + ..., description="see `OrderParameters::buyTokenBalance`" + ) + class_: OrderClass = Field(..., alias="class") + appData: AppDataHash + signature: Signature + protocolFees: List[FeePolicy] = Field( + ..., + description="The fee policies that are used to compute the protocol fees for this order.\n", + ) + + +class Auction(BaseModel): + id: Optional[int] = Field( + None, + description="The unique identifier of the auction. Increment whenever the backend creates a new auction.\n", + ) + block: Optional[int] = Field( + None, + description="The block number for the auction. Orders and prices are guaranteed to be valid on this\nblock. Proposed settlements should be valid for this block as well.\n", + ) + latestSettlementBlock: Optional[int] = Field( + None, + description="The latest block on which a settlement has been processed.\n\n**NOTE**: Under certain conditions it is possible for a settlement to have been mined as\npart of `block` but not have yet been processed.\n", + ) + orders: Optional[List[AuctionOrder]] = Field( + None, description="The solvable orders included in the auction.\n" + ) + prices: Optional[AuctionPrices] = None diff --git a/cow_py/web3/provider.py b/cow_py/web3/provider.py index fc5a95d..7087d24 100644 --- a/cow_py/web3/provider.py +++ b/cow_py/web3/provider.py @@ -1,6 +1,7 @@ from typing import Dict import web3 + from cow_py.common.chains import Chain DEFAULT_PROVIDER_NETWORK_MAPPING = { diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/order_posting_e2e.py b/examples/order_posting_e2e.py new file mode 100644 index 0000000..3d2c784 --- /dev/null +++ b/examples/order_posting_e2e.py @@ -0,0 +1,116 @@ +# To run this test you will need to fill the .env file with the necessary variables (see .env.example). +# You will also need to have enough funds in you wallet of the sell token to create the order. +# The funds have to already be approved to the CoW Swap Vault Relayer + +import asyncio +import json +import os + +from web3 import Account + +from cow_py.common.chains import Chain +from cow_py.common.config import SupportedChainId +from cow_py.common.constants import CowContractAddress +from cow_py.contracts.domain import domain +from cow_py.contracts.order import Order +from cow_py.contracts.sign import EcdsaSignature, SigningScheme +from cow_py.contracts.sign import sign_order as _sign_order +from cow_py.order_book.api import OrderBookApi +from cow_py.order_book.config import OrderBookAPIConfigFactory +from cow_py.order_book.generated.model import OrderQuoteSide1, TokenAmount +from cow_py.order_book.generated.model import OrderQuoteSideKindSell +from cow_py.order_book.generated.model import ( + UID, + OrderCreation, + OrderQuoteRequest, + OrderQuoteResponse, +) + +BUY_TOKEN = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" # WETH +SELL_TOKEN = "0xbe72E441BF55620febc26715db68d3494213D8Cb" # USDC +SELL_AMOUNT_BEFORE_FEE = "10000000000000000000" # 100 USDC with 18 decimals +ORDER_KIND = "sell" +CHAIN = Chain.SEPOLIA +CHAIN_ID = SupportedChainId.SEPOLIA + +config = OrderBookAPIConfigFactory.get_config("prod", CHAIN_ID) +ORDER_BOOK_API = OrderBookApi(config) + +ADDRESS = os.getenv("USER_ADDRESS") +ACCOUNT = Account.from_key(os.getenv("PRIVATE_KEY")) + + +async def get_order_quote( + order_quote_request: OrderQuoteRequest, order_side: OrderQuoteSide1 +) -> OrderQuoteResponse: + return await ORDER_BOOK_API.post_quote(order_quote_request, order_side) + + +def sign_order(order: Order) -> EcdsaSignature: + order_domain = domain( + chain=CHAIN, verifying_contract=CowContractAddress.SETTLEMENT_CONTRACT.value + ) + + return _sign_order(order_domain, order, ACCOUNT, SigningScheme.EIP712) + + +async def post_order(order: Order, signature: EcdsaSignature) -> UID: + order_creation = OrderCreation( + **{ + "from": ADDRESS, + "sellToken": order.sellToken, + "buyToken": order.buyToken, + "sellAmount": order.sellAmount, + "feeAmount": order.feeAmount, + "buyAmount": order.buyAmount, + "validTo": order.validTo, + "kind": order.kind, + "partiallyFillable": order.partiallyFillable, + "appData": order.appData, + "signature": signature.data, + "signingScheme": "eip712", + "receiver": order.receiver, + }, + ) + return await ORDER_BOOK_API.post_order(order_creation) + + +async def main(): + order_quote_request = OrderQuoteRequest( + **{ + "sellToken": SELL_TOKEN, + "buyToken": BUY_TOKEN, + "from": ADDRESS, + } + ) + order_side = OrderQuoteSide1( + kind=OrderQuoteSideKindSell.sell, + sellAmountBeforeFee=TokenAmount(SELL_AMOUNT_BEFORE_FEE), + ) + + order_quote = await get_order_quote(order_quote_request, order_side) + + order_quote_dict = json.loads(order_quote.quote.model_dump_json(by_alias=True)) + order = Order( + **{ + "sellToken": SELL_TOKEN, + "buyToken": BUY_TOKEN, + "receiver": ADDRESS, + "validTo": order_quote_dict["validTo"], + "appData": "0x0000000000000000000000000000000000000000000000000000000000000000", + "sellAmount": SELL_AMOUNT_BEFORE_FEE, # Since it is a sell order, the sellAmountBeforeFee is the same as the sellAmount + "buyAmount": order_quote_dict["buyAmount"], + "feeAmount": "0", # CoW Swap does not charge fees + "kind": ORDER_KIND, + "sellTokenBalance": "erc20", + "buyTokenBalance": "erc20", + } + ) + + signature = sign_order(order) + order_uid = await post_order(order, signature) + print(f"order posted on link: {ORDER_BOOK_API.get_order_link(order_uid)}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/contracts/conftest.py b/tests/contracts/conftest.py new file mode 100644 index 0000000..5866e5c --- /dev/null +++ b/tests/contracts/conftest.py @@ -0,0 +1,47 @@ +from eth_utils.conversions import to_hex +from eth_utils.crypto import keccak +from eth_utils.currency import to_wei + +from cow_py.contracts.domain import TypedDataDomain +from cow_py.contracts.order import Order + + +def fill_bytes(count, byte): + return to_hex(bytearray([byte] * count)) + + +def fill_distinct_bytes(count, start): + return to_hex(bytearray([(start + i) % 256 for i in range(count)])) + + +def fill_uint(bits, byte): + return int(fill_bytes(bits // 8, byte), 16) + + +def ceil_div(p, q): + return (p + q - 1) // q + + +ORDER_KIND_SELL = "SELL" + +SAMPLE_ORDER = Order( + **{ + "sell_token": fill_bytes(20, 0x01), + "buy_token": fill_bytes(20, 0x02), + "receiver": fill_bytes(20, 0x03), + "sell_amount": to_wei("42", "ether"), + "buy_amount": to_wei("13.37", "ether"), + "valid_to": 0xFFFFFFFF, + "app_data": keccak(text="")[0:20], + "fee_amount": to_wei("1.0", "ether"), + "kind": ORDER_KIND_SELL, + "partially_fillable": False, + } +) + +SAMPLE_DOMAIN = TypedDataDomain( + name="Gnosis Protocol", + version="v2", + chainId=1, + verifyingContract="0x9008D19f58AAbD9eD0D60971565AA8510560ab41", +) diff --git a/tests/contracts/test_orders.py b/tests/contracts/test_orders.py new file mode 100644 index 0000000..98fd80c --- /dev/null +++ b/tests/contracts/test_orders.py @@ -0,0 +1,104 @@ +import pytest + +from cow_py.contracts.order import ( + Order, + hashify, + normalize_buy_token_balance, + normalize_order, + hash_order, + hash_order_cancellation, + hash_order_cancellations, + OrderUidParams, +) +from .conftest import SAMPLE_DOMAIN, SAMPLE_ORDER + + +def test_order_dataclass(): + order = Order( + sell_token="0x1111111111111111111111111111111111111111", + buy_token="0x2222222222222222222222222222222222222222", + receiver="0x3333333333333333333333333333333333333333", + sell_amount=1000000000000000000, + buy_amount=2000000000000000000, + valid_to=1735689600, + app_data="0x1234567890123456789012345678901234567890123456789012345678901234", + fee_amount=1000000000000000, + kind="sell", + ) + + assert order.sell_token == "0x1111111111111111111111111111111111111111" + assert order.buyToken == "0x2222222222222222222222222222222222222222" + assert order.sellAmount == 1000000000000000000 + + +def test_hashify(): + assert ( + hashify(123) + == "0x000000000000000000000000000000000000000000000000000000000000007b" + ) + assert ( + hashify("1234") + == "0x0000000000000000000000000000000000000000000000000000000000001234" + ) + assert ( + hashify(b"\x12\x34") + == "0x0000000000000000000000000000000000000000000000000000000000001234" + ) + + +def test_normalize_buy_token_balance(): + assert normalize_buy_token_balance(None) == "erc20" + assert normalize_buy_token_balance("erc20") == "erc20" + assert normalize_buy_token_balance("internal") == "internal" + + with pytest.raises(ValueError): + normalize_buy_token_balance("invalid") + + +def test_normalize_order(): + normalized = normalize_order(SAMPLE_ORDER) + + assert normalized["sellToken"] == SAMPLE_ORDER.sell_token + assert normalized["buyToken"] == SAMPLE_ORDER.buy_token + assert normalized["sellAmount"] == SAMPLE_ORDER.sell_amount + assert normalized["kind"] == SAMPLE_ORDER.kind + assert normalized["partiallyFillable"] == SAMPLE_ORDER.partially_fillable + assert normalized["sellTokenBalance"] == "erc20" + assert normalized["buyTokenBalance"] == "erc20" + + +def test_hash_order(): + order_hash = hash_order(SAMPLE_DOMAIN, SAMPLE_ORDER) + assert isinstance(order_hash, bytes) + assert len(order_hash) == 32 + + +def test_hash_order_cancellation(): + order_uid = b"0" * 56 + cancellation_hash = hash_order_cancellation(SAMPLE_DOMAIN, order_uid.hex()) + assert isinstance(cancellation_hash, str) + assert len(cancellation_hash) == 64 # 32 bytes in hex + + +def test_hash_order_cancellations(): + order_uids = [b"0" * 56, b"1" * 56] + cancellations_hash = hash_order_cancellations( + SAMPLE_DOMAIN, [uid.hex() for uid in order_uids] + ) + assert isinstance(cancellations_hash, str) + assert len(cancellations_hash) == 64 # 32 bytes in hex + + +def test_order_uid_params(): + params = OrderUidParams( + order_digest="0x1234567890123456789012345678901234567890123456789012345678901234", + owner="0x1111111111111111111111111111111111111111", + validTo=1735689600, + ) + + assert ( + params.order_digest + == "0x1234567890123456789012345678901234567890123456789012345678901234" + ) + assert params.owner == "0x1111111111111111111111111111111111111111" + assert params.validTo == 1735689600 diff --git a/tests/contracts/test_sign.py b/tests/contracts/test_sign.py new file mode 100644 index 0000000..1fb9f97 --- /dev/null +++ b/tests/contracts/test_sign.py @@ -0,0 +1,71 @@ +import pytest +from eth_account.messages import SignableMessage +from eth_account.signers.local import LocalAccount +from eth_utils.conversions import to_hex +from web3 import EthereumTesterProvider, Web3 + +from cow_py.contracts.order import hash_order_cancellation + +from cow_py.contracts.sign import SigningScheme, sign_order, sign_order_cancellation + +from .conftest import SAMPLE_DOMAIN, SAMPLE_ORDER + +w3 = Web3(EthereumTesterProvider()) + + +def patched_sign_message_builder(account: LocalAccount): + def sign_message(message): + # Determine the correct message format + if isinstance(message, SignableMessage): + message_to_hash = message.body + elif isinstance(message, (bytes, str)): + message_to_hash = message + else: + raise TypeError("Unsupported message type for signing.") + + # Hash and sign the message + message_hash = Web3.solidity_keccak(["bytes"], [message_to_hash]) + signature = account.signHash(message_hash) + r, s, v = signature["r"], signature["s"], signature["v"] + + # Concatenate the signature components into a hex string + signature_hex = to_hex(r)[2:] + to_hex(s)[2:] + hex(v)[2:] + + return signature_hex + + return sign_message + + +@pytest.mark.asyncio +@pytest.mark.parametrize("scheme", [SigningScheme.EIP712, SigningScheme.ETHSIGN]) +async def test_sign_order(monkeypatch, scheme): + signer = w3.eth.account.create() + + patched_sign_message = patched_sign_message_builder(signer) + + # Use monkeypatch to temporarily replace sign_message + monkeypatch.setattr(signer, "sign_message", patched_sign_message) + + signed_order = sign_order(SAMPLE_DOMAIN, SAMPLE_ORDER, signer, scheme) + + # Extract 'v' value from the last two characters of the signature + v = signed_order.data[-2:] + + assert v in ["1b", "1c"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "scheme", [SigningScheme.EIP712.value, SigningScheme.ETHSIGN.value] +) +async def test_sign_order_cancellation(scheme): + signer = w3.eth.account.create() + order_uid = "0x" + "2a" * 56 + + signature_data = sign_order_cancellation(SAMPLE_DOMAIN, order_uid, signer, scheme) + order_hash = hash_order_cancellation(SAMPLE_DOMAIN, order_uid) + + assert ( + w3.eth.account._recover_hash(order_hash, signature=signature_data.data) + == signer.address + ) diff --git a/tests/order_book/test_api.py b/tests/order_book/test_api.py new file mode 100644 index 0000000..72f5d22 --- /dev/null +++ b/tests/order_book/test_api.py @@ -0,0 +1,147 @@ +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from cow_py.order_book.api import OrderBookApi +from cow_py.order_book.generated.model import OrderQuoteSide1 +from cow_py.order_book.generated.model import OrderQuoteSideKindSell +from cow_py.order_book.generated.model import TokenAmount +from cow_py.order_book.generated.model import ( + OrderQuoteRequest, + OrderQuoteResponse, + Trade, + OrderCreation, +) + + +@pytest.fixture +def order_book_api(): + return OrderBookApi() + + +@pytest.mark.asyncio +async def test_get_version(order_book_api): + expected_version = "1.0.0" + with patch("httpx.AsyncClient.request", new_callable=AsyncMock) as mock_request: + mock_request.return_value = AsyncMock( + status_code=200, + text=expected_version, + ) + version = await order_book_api.get_version() + + mock_request.assert_awaited_once() + assert version == expected_version + + +@pytest.mark.asyncio +async def test_get_trades_by_order_uid(order_book_api): + mock_trade_data = [ + { + "blockNumber": 123456, + "logIndex": 789, + "orderUid": "mock_order_uid", + "owner": "mock_owner_address", + "sellToken": "mock_sell_token_address", + "buyToken": "mock_buy_token_address", + "sellAmount": "100", + "sellAmountBeforeFees": "120", + "buyAmount": "200", + "txHash": "mock_transaction_hash", + } + ] + mock_trade = Trade(**mock_trade_data[0]) + with patch("httpx.AsyncClient.request", new_callable=AsyncMock) as mock_request: + mock_request.return_value = AsyncMock( + status_code=200, + headers={"content-type": "application/json"}, + json=Mock(return_value=mock_trade_data), + ) + trades = await order_book_api.get_trades_by_order_uid("mock_order_uid") + mock_request.assert_awaited_once() + assert trades == [mock_trade] + + +@pytest.mark.asyncio +async def test_post_quote(order_book_api): + mock_order_quote_request = OrderQuoteRequest( + **{ + "sellToken": "0x", + "buyToken": "0x", + "receiver": "0x", + "appData": "app_data_object", + "appDataHash": "0x", + "from": "0x", + "priceQuality": "verified", + "signingScheme": "eip712", + "onchainOrder": False, + } + ) + + mock_order_quote_side = OrderQuoteSide1( + sellAmountBeforeFee=TokenAmount("0"), kind=OrderQuoteSideKindSell.sell + ) + mock_order_quote_response_data = { + "quote": { + "sellToken": "0x", + "buyToken": "0x", + "receiver": "0x", + "sellAmount": "0", + "buyAmount": "0", + "feeAmount": "0", + "validTo": 0, + "appData": "0x", + "partiallyFillable": True, + "sellTokenBalance": "erc20", + "buyTokenBalance": "erc20", + "kind": "buy", + }, + "verified": True, + "from": "0x", + "expiration": "0", + } + mock_order_quote_response = OrderQuoteResponse(**mock_order_quote_response_data) + with patch("httpx.AsyncClient.request", new_callable=AsyncMock) as mock_request: + mock_request.return_value = AsyncMock( + status_code=200, + headers={"content-type": "application/json"}, + json=Mock(return_value=mock_order_quote_response_data), + ) + response = await order_book_api.post_quote( + mock_order_quote_request, mock_order_quote_side + ) + mock_request.assert_awaited_once() + assert response == mock_order_quote_response + + +@pytest.mark.asyncio +async def test_post_order(order_book_api): + mock_response = "mock_uid" + mock_order_creation = OrderCreation( + **{ + "sellToken": "0x", + "buyToken": "0x", + "sellAmount": "0", + "buyAmount": "0", + "validTo": 0, + "feeAmount": "0", + "kind": "buy", + "partiallyFillable": True, + "appData": "0x", + "signingScheme": "eip712", + "signature": "0x", + "receiver": "0x", + "sellTokenBalance": "erc20", + "buyTokenBalance": "erc20", + "quoteId": 0, + "appDataHash": "0x", + "from_": "0x", + } + ) + with patch("httpx.AsyncClient.request", new_callable=AsyncMock) as mock_request: + mock_request.return_value = AsyncMock( + status_code=200, + text=mock_response, + ) + response = await order_book_api.post_order(mock_order_creation) + mock_request.assert_awaited_once() + assert response.root == mock_response