Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Post order using cow-py #580

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions prediction_market_agent_tooling/config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import typing as t

from eth_account.signers.local import LocalAccount
from pydantic import Field
from pydantic.types import SecretStr
from pydantic.v1.types import SecretStr as SecretStrV1
from pydantic_settings import BaseSettings, SettingsConfigDict
from safe_eth.eth import EthereumClient
from safe_eth.safe.safe import SafeV141
from web3 import Account

from prediction_market_agent_tooling.gtypes import (
ChainID,
Expand Down Expand Up @@ -184,6 +186,12 @@ def sqlalchemy_db_url(self) -> SecretStr:
self.SQLALCHEMY_DB_URL, "SQLALCHEMY_DB_URL missing in the environment."
)

def get_account(self) -> LocalAccount:
acc: LocalAccount = Account.from_key(
self.bet_from_private_key.get_secret_value()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Secure handling of private keys

Ensure that the private key is never logged or exposed in error messages. Consider adding validation to check if the private key is in the correct format and handle any exceptions securely.

)
return acc

def model_dump_public(self) -> dict[str, t.Any]:
return {
k: v
Expand Down
153 changes: 153 additions & 0 deletions prediction_market_agent_tooling/tools/cow_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import asyncio

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 Envs, OrderBookAPIConfigFactory
from cow_py.order_book.generated.model import (
UID,
OrderCreation,
OrderQuoteRequest,
OrderQuoteResponse,
OrderQuoteSide1,
OrderQuoteSideKindSell,
TokenAmount,
)
from eth_account.signers.local import LocalAccount
from pydantic import BaseModel
from web3 import Web3

from prediction_market_agent_tooling.config import APIKeys
from prediction_market_agent_tooling.gtypes import ChecksumAddress, xDai
from prediction_market_agent_tooling.tools.contract import ContractERC20OnGnosisChain
from prediction_market_agent_tooling.tools.web3_utils import xdai_to_wei

ZERO_APP_DATA = "0x0000000000000000000000000000000000000000000000000000000000000000"


class CompletedOrder(BaseModel):
uid: UID
url: str


def swap_tokens(
amount: xDai,
sell_token: ChecksumAddress,
buy_token: ChecksumAddress,
api_keys: APIKeys,
chain: Chain = Chain.GNOSIS,
app_data: str = ZERO_APP_DATA,
env: Envs = "prod",
web3: Web3 | None = None,
) -> CompletedOrder:
# CoW library uses async, so we need to wrap the call in asyncio.run for us to use it.
return asyncio.run(
swap_tokens_async(
amount, sell_token, buy_token, api_keys, chain, app_data, env, web3
)
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid using asyncio.run() in libraries

Using asyncio.run() within library code can cause issues if the caller is already running an event loop (e.g., in an asynchronous context). Consider refactoring swap_tokens to be asynchronous or providing separate synchronous and asynchronous interfaces.

Apply this diff to refactor the function:

-def swap_tokens(
+async def swap_tokens(
     amount: xDai,
     sell_token: ChecksumAddress,
     buy_token: ChecksumAddress,
     api_keys: APIKeys,
     chain: Chain = Chain.GNOSIS,
     app_data: str = ZERO_APP_DATA,
     env: Envs = "prod",
     web3: Web3 | None = None,
 ) -> CompletedOrder:
-    # CoW library uses async, so we need to wrap the call in asyncio.run for us to use it.
-    return asyncio.run(
-        swap_tokens_async(
-            amount, sell_token, buy_token, api_keys, chain, app_data, env, web3
-        )
-    )
+    return await swap_tokens_async(
+        amount, sell_token, buy_token, api_keys, chain, app_data, env, web3
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return asyncio.run(
swap_tokens_async(
amount, sell_token, buy_token, api_keys, chain, app_data, env, web3
)
)
async def swap_tokens(
amount: xDai,
sell_token: ChecksumAddress,
buy_token: ChecksumAddress,
api_keys: APIKeys,
chain: Chain = Chain.GNOSIS,
app_data: str = ZERO_APP_DATA,
env: Envs = "prod",
web3: Web3 | None = None,
) -> CompletedOrder:
return await swap_tokens_async(
amount, sell_token, buy_token, api_keys, chain, app_data, env, web3
)



async def swap_tokens_async(
amount: xDai,
sell_token: ChecksumAddress,
buy_token: ChecksumAddress,
api_keys: APIKeys,
chain: Chain,
app_data: str,
env: Envs,
web3: Web3 | None,
) -> CompletedOrder:
account = api_keys.get_account()
amount_wei = xdai_to_wei(amount)
chain_id = SupportedChainId(chain.value[0])

order_book_api = OrderBookApi(OrderBookAPIConfigFactory.get_config(env, chain_id))

# Approve the CoW Swap Vault Relayer to get the sell token.
ContractERC20OnGnosisChain(address=sell_token).approve(
api_keys,
Web3.to_checksum_address(CowContractAddress.VAULT_RELAYER.value),
amount_wei=amount_wei,
web3=web3,
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for token approval

The approve function may fail due to various reasons (e.g., network issues, insufficient funds). It's recommended to handle exceptions to ensure the approval process is robust.

Apply this diff to add error handling:

try:
    ContractERC20OnGnosisChain(address=sell_token).approve(
        api_keys,
        Web3.to_checksum_address(CowContractAddress.VAULT_RELAYER.value),
        amount_wei=amount_wei,
        web3=web3,
    )
except Exception as e:
    # Handle exception appropriately
    raise RuntimeError(f"Token approval failed: {e}")


order_quote_request = OrderQuoteRequest.model_validate(
{
"sellToken": sell_token,
"buyToken": buy_token,
"from": api_keys.bet_from_address,
}
)
order_side = OrderQuoteSide1(
kind=OrderQuoteSideKindSell.sell,
sellAmountBeforeFee=TokenAmount(str(amount_wei)),
)

order_quote = await get_order_quote(order_quote_request, order_side, order_book_api)
order = Order(
sell_token=sell_token,
buy_token=buy_token,
receiver=api_keys.bet_from_address,
valid_to=order_quote.quote.validTo,
app_data=app_data,
sell_amount=amount_wei, # Since it is a sell order, the sellAmountBeforeFee is the same as the sellAmount.
buy_amount=int(order_quote.quote.buyAmount.root),
fee_amount=0, # CoW Swap does not charge fees.
kind=OrderQuoteSideKindSell.sell.value,
sell_token_balance="erc20",
buy_token_balance="erc20",
)

signature = sign_order(chain, account, order)
order_uid = await post_order(api_keys, order, signature, order_book_api)
order_link = order_book_api.get_order_link(order_uid)

return CompletedOrder(uid=order_uid, url=order_link)


async def get_order_quote(
order_quote_request: OrderQuoteRequest,
order_side: OrderQuoteSide1,
order_book_api: OrderBookApi,
) -> OrderQuoteResponse:
return await order_book_api.post_quote(order_quote_request, order_side)


def sign_order(chain: Chain, account: LocalAccount, 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(
api_keys: APIKeys,
order: Order,
signature: EcdsaSignature,
order_book_api: OrderBookApi,
) -> UID:
order_creation = OrderCreation.model_validate(
{
"from": api_keys.bet_from_address,
"sellToken": order.sellToken,
"buyToken": order.buyToken,
"sellAmount": str(order.sellAmount),
"feeAmount": str(order.feeAmount),
"buyAmount": str(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)
25 changes: 25 additions & 0 deletions tests_integration_with_local_chain/tools/test_cow_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import pytest
from web3 import Web3

from prediction_market_agent_tooling.config import APIKeys
from prediction_market_agent_tooling.gtypes import xdai_type
from prediction_market_agent_tooling.markets.omen.omen_contracts import (
WrappedxDaiContract,
sDaiContract,
)
from prediction_market_agent_tooling.tools.cow_order import swap_tokens


def test_swap_tokens(local_web3: Web3, test_keys: APIKeys) -> None:
with pytest.raises(Exception) as e:
swap_tokens(
amount=xdai_type(1),
sell_token=WrappedxDaiContract().address,
buy_token=sDaiContract().address,
api_keys=test_keys,
env="staging",
web3=local_web3,
)
# This is raised in `post_order` which is last call when swapping tokens, anvil's accounts don't have any balance on real chain, so this is expected,
# but still, it tests that all the logic behind calling CoW APIs is working correctly.
assert "InsufficientBalance" in str(e)
Comment on lines +23 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

Loading