diff --git a/.env.example b/.env.example index f0f08f0..6fc16b3 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1 @@ -USER_ADDRESS= PRIVATE_KEY= 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..bfec9a2 --- /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-04-12T14:44:16+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 \ No newline at end of file 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..e0e97ef --- /dev/null +++ b/examples/order_posting_e2e.py @@ -0,0 +1,128 @@ +# 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 dataclasses import asdict + +from dotenv import load_dotenv +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 ( + OrderQuoteSideKindSell, + OrderQuoteSide1, + OrderCreation, + OrderQuoteRequest, + OrderQuoteResponse, + UID, + TokenAmount, +) + +BUY_TOKEN = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" # WETH +SELL_TOKEN = "0xbe72E441BF55620febc26715db68d3494213D8Cb" # USDC +SELL_AMOUNT_BEFORE_FEE = "5000000000000000000" # 50 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) + +load_dotenv() + +PRIVATE_KEY = os.getenv("PRIVATE_KEY") + +if not PRIVATE_KEY: + raise ValueError("Missing variables on .env file") + +ACCOUNT = Account.from_key(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 = asdict( + domain( + chain=CHAIN, verifying_contract=CowContractAddress.SETTLEMENT_CONTRACT.value + ) + ) + del order_domain["salt"] # TODO: improve interfaces + + return _sign_order(order_domain, order, ACCOUNT, SigningScheme.EIP712) + + +async def post_order(order: Order, signature: EcdsaSignature) -> UID: + order_creation = OrderCreation( + **{ + "from": ACCOUNT.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": ACCOUNT.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": ACCOUNT.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/poetry.lock b/poetry.lock index ce74efa..1650d28 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2540,6 +2540,20 @@ pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "pyunormalize" version = "15.1.0" @@ -2585,6 +2599,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -2592,8 +2607,16 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -2610,6 +2633,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -2617,6 +2641,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -3417,4 +3442,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "e02971366a8bd5c85b5e2c32578cd193560f4827d381c2e9341953d453120fad" +content-hash = "cfb0f96417822d96b88102108c501da664fbc58c0cd6f6771524e2152964d0ba" diff --git a/pyproject.toml b/pyproject.toml index 012d3ac..6bb58ad 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" +python-dotenv = "^1.0.1" [tool.poetry.group.dev.dependencies] 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