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

496 seer market creation on pmat #606

Merged
merged 20 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
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
758 changes: 373 additions & 385 deletions poetry.lock

Large diffs are not rendered by default.

609 changes: 609 additions & 0 deletions prediction_market_agent_tooling/abis/seer_market_factory.abi.json

Large diffs are not rendered by default.

36 changes: 34 additions & 2 deletions prediction_market_agent_tooling/markets/seer/data_models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
import typing as t

from eth_typing import HexAddress
from pydantic import BaseModel, ConfigDict, Field
from web3.constants import ADDRESS_ZERO

from prediction_market_agent_tooling.gtypes import HexBytes, Wei


from prediction_market_agent_tooling.gtypes import HexBytes
class CreateCategoricalMarketsParams(BaseModel):
model_config = ConfigDict(populate_by_name=True)

market_name: str = Field(..., alias="marketName")
outcomes: list[str]
# Only relevant for scalar markets
question_start: t.Optional[str] = Field(alias="questionStart", default="")
Copy link
Contributor

Choose a reason for hiding this comment

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

If it's optional, then default should be None. Or it's not optional, and the value should be empty string if not present? (first case is cleaner)

Suggested change
question_start: t.Optional[str] = Field(alias="questionStart", default="")
question_start: t.Optional[str] = Field(alias="questionStart")

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As discussed on Slack, closing this since a default value is required to use t.Optional.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think there is misunderstanding.

On Slack we discussed doing question_start: t.Optional[str] = Field(None, alias="questionStart"), which means that value will default to None, if not provided, which is correct behaviour.

question_start: t.Optional[str] = Field(alias="questionStart", default="") is incorrect behaviour, because if value is not provided, it will be "" (which surprising to user, why an empty string?), but it will never be None (although it should be, because it's typed as Optional)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree that t.Optional -> and default value "" is not correct.
Note that I tested setting e.g. question_start = None and the send tx fails. Hence I will change the type but keep the default value, i.e. None is not accepted.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 133132d

question_end: t.Optional[str] = Field(alias="questionEnd", default="")
gabrielfior marked this conversation as resolved.
Show resolved Hide resolved
outcome_type: t.Optional[str] = Field(alias="outcomeType", default="")
gabrielfior marked this conversation as resolved.
Show resolved Hide resolved

# Not needed for non-conditional markets.
parent_outcome: t.Optional[int] = Field(alias="parentOutcome", default=0)
gabrielfior marked this conversation as resolved.
Show resolved Hide resolved
parent_market: t.Optional[HexAddress] = Field(
alias="parentMarket", default=ADDRESS_ZERO
gabrielfior marked this conversation as resolved.
Show resolved Hide resolved
)

category: str
lang: str
lower_bound: t.Optional[int] = Field(alias="lowerBound", default=0)
gabrielfior marked this conversation as resolved.
Show resolved Hide resolved
upper_bound: t.Optional[int] = Field(alias="upperBound", default=0)
gabrielfior marked this conversation as resolved.
Show resolved Hide resolved
min_bond: Wei = Field(..., alias="minBond")
opening_time: int = Field(..., alias="openingTime")
token_names: list[str] = Field(..., alias="tokenNames")


class SeerParentMarket(BaseModel):
Expand All @@ -13,8 +42,11 @@ class SeerMarket(BaseModel):
id: HexBytes
title: str = Field(alias="marketName")
outcomes: list[str]
parent_market: SeerParentMarket | None = Field(alias="parentMarket")
wrapped_tokens: list[HexBytes] = Field(alias="wrappedTokens")
parent_outcome: int = Field(alias="parentOutcome")
parent_market: t.Optional[SeerParentMarket] = Field(
alias="parentMarket", default=None
)


class SeerToken(BaseModel):
Expand Down
77 changes: 77 additions & 0 deletions prediction_market_agent_tooling/markets/seer/seer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from eth_typing import ChecksumAddress
from web3 import Web3

from prediction_market_agent_tooling.config import APIKeys
from prediction_market_agent_tooling.gtypes import xDai
from prediction_market_agent_tooling.markets.seer.seer_contracts import (
SeerMarketFactory,
)
from prediction_market_agent_tooling.tools.contract import (
to_gnosis_chain_contract,
init_collateral_token_contract,
auto_deposit_collateral_token,
)
from prediction_market_agent_tooling.tools.datetime_utc import DatetimeUTC
from prediction_market_agent_tooling.tools.web3_utils import xdai_to_wei


def seer_create_market_tx(
api_keys: APIKeys,
initial_funds: xDai,
question: str,
opening_time: DatetimeUTC,
language: str,
outcomes: list[str],
auto_deposit: bool,
category: str,
min_bond_xdai: xDai,
web3: Web3 | None = None,
) -> ChecksumAddress:
web3 = web3 or SeerMarketFactory.get_web3() # Default to Gnosis web3.
initial_funds_wei = xdai_to_wei(initial_funds)

factory_contract = SeerMarketFactory()
collateral_token_address = factory_contract.collateral_token(web3=web3)
collateral_token_contract = to_gnosis_chain_contract(
init_collateral_token_contract(collateral_token_address, web3)
)

if auto_deposit:
auto_deposit_collateral_token(
collateral_token_contract=collateral_token_contract,
api_keys=api_keys,
amount_wei=initial_funds_wei,
web3=web3,
)

# In case of ERC4626, obtained (for example) sDai out of xDai could be lower than the `amount_wei`, so we need to handle it.
initial_funds_in_shares = collateral_token_contract.get_in_shares(
amount=initial_funds_wei, web3=web3
)

# Approve the market maker to withdraw our collateral token.
collateral_token_contract.approve(
api_keys=api_keys,
for_address=factory_contract.address,
amount_wei=initial_funds_in_shares,
web3=web3,
)

# Create the market.
params = factory_contract.build_market_params(
market_question=question,
outcomes=outcomes,
opening_time=opening_time,
language=language,
category=category,
min_bond_xdai=min_bond_xdai,
)
factory_contract.create_categorical_market(
api_keys=api_keys, params=params, web3=web3
)
# ToDo - Add liquidity to market on Swapr (https://github.com/gnosis/prediction-market-agent-tooling/issues/497)

# Fetch newly created market
count_markets = factory_contract.market_count(web3=web3)
new_market_address = factory_contract.market_at_index(count_markets - 1, web3=web3)
Copy link
Contributor

Choose a reason for hiding this comment

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

If two people create a market simultaneously, won't this go out of sync? This function could potentially return another market address.

Can't it be obtained from logs or something like in Omen case?

Copy link
Contributor Author

@gabrielfior gabrielfior Feb 6, 2025

Choose a reason for hiding this comment

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

return new_market_address
83 changes: 83 additions & 0 deletions prediction_market_agent_tooling/markets/seer/seer_contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import os

from web3 import Web3
from web3.types import TxReceipt

from prediction_market_agent_tooling.config import APIKeys
from prediction_market_agent_tooling.gtypes import (
ABI,
ChecksumAddress,
xDai,
xdai_type,
)
from prediction_market_agent_tooling.markets.seer.data_models import (
CreateCategoricalMarketsParams,
)
from prediction_market_agent_tooling.tools.contract import (
abi_field_validator,
ContractOnGnosisChain,
)
from prediction_market_agent_tooling.tools.datetime_utc import DatetimeUTC
from prediction_market_agent_tooling.tools.web3_utils import xdai_to_wei


class SeerMarketFactory(ContractOnGnosisChain):
# https://gnosisscan.io/address/0x83183da839ce8228e31ae41222ead9edbb5cdcf1#code.
abi: ABI = abi_field_validator(
os.path.join(
os.path.dirname(os.path.realpath(__file__)),
"../../abis/seer_market_factory.abi.json",
)
)
address: ChecksumAddress = Web3.to_checksum_address(
"0x83183da839ce8228e31ae41222ead9edbb5cdcf1"
)

@staticmethod
def build_market_params(
market_question: str,
outcomes: list[str],
opening_time: DatetimeUTC,
min_bond_xdai: xDai = xdai_type(10),
gabrielfior marked this conversation as resolved.
Show resolved Hide resolved
gabrielfior marked this conversation as resolved.
Show resolved Hide resolved
language: str = "en_US",
category: str = "misc",
) -> CreateCategoricalMarketsParams:
return CreateCategoricalMarketsParams(
market_name=market_question,
token_names=[
o.upper() for o in outcomes
], # Following usual token names on Seer (YES,NO).
kongzii marked this conversation as resolved.
Show resolved Hide resolved
min_bond=xdai_to_wei(min_bond_xdai),
opening_time=int(opening_time.timestamp()),
outcomes=outcomes,
lang=language,
category=category,
)

def market_count(self, web3: Web3 | None = None) -> int:
count: int = self.call("marketCount", web3=web3)
return count

def market_at_index(self, index: int, web3: Web3 | None = None) -> ChecksumAddress:
market_address: str = self.call("markets", function_params=[index], web3=web3)
return Web3.to_checksum_address(market_address)

def collateral_token(self, web3: Web3 | None = None) -> ChecksumAddress:
collateral_token_address: str = self.call("collateralToken", web3=web3)
return Web3.to_checksum_address(collateral_token_address)

def create_categorical_market(
self,
api_keys: APIKeys,
params: CreateCategoricalMarketsParams,
web3: Web3 | None = None,
) -> TxReceipt:
receipt_tx = self.send(
api_keys=api_keys,
function_name="createCategoricalMarket",
function_params=[params.model_dump(by_alias=True)],
web3=web3,
)
return receipt_tx

# ToDo - Also return event NewMarket, emitted by this contract
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Implement event handling for NewMarket event.

The TODO comment indicates that the NewMarket event emitted by the contract should be handled. This event likely contains important information about the created market.

Consider updating the create_categorical_market method to parse and return the event data:

 def create_categorical_market(
     self,
     api_keys: APIKeys,
     params: CreateCategoricalMarketsParams,
     web3: Web3 | None = None,
 ) -> TxReceipt:
     receipt_tx = self.send(
         api_keys=api_keys,
         function_name="createCategoricalMarket",
         function_params=[params.model_dump(by_alias=True)],
         web3=web3,
     )
+    # Parse NewMarket event from receipt
+    new_market_event = self.get_event_from_receipt(receipt_tx, "NewMarket")
+    if not new_market_event:
+        raise ValueError("NewMarket event not found in transaction receipt")
     return receipt_tx
📝 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
# ToDo - Also return event NewMarket, emitted by this contract
def create_categorical_market(
self,
api_keys: APIKeys,
params: CreateCategoricalMarketsParams,
web3: Web3 | None = None,
) -> TxReceipt:
receipt_tx = self.send(
api_keys=api_keys,
function_name="createCategoricalMarket",
function_params=[params.model_dump(by_alias=True)],
web3=web3,
)
# Parse NewMarket event from receipt
new_market_event = self.get_event_from_receipt(receipt_tx, "NewMarket")
if not new_market_event:
raise ValueError("NewMarket event not found in transaction receipt")
return receipt_tx

Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def _get_fields_for_markets(self, markets_field: FieldPath) -> list[FieldPath]:
markets_field.factory,
markets_field.creator,
markets_field.marketName,
markets_field.parentOutcome,
markets_field.outcomes,
markets_field.parentMarket.id,
markets_field.finalizeTs,
Expand Down Expand Up @@ -126,7 +127,7 @@ def _get_fields_for_pools(self, pools_field: FieldPath) -> list[FieldPath]:
]
return fields

def get_pools_for_market(self, market: SeerMarket) -> list[SeerPool]:
def get_swapr_pools_for_market(self, market: SeerMarket) -> list[SeerPool]:
# We iterate through the wrapped tokens and put them in a where clause so that we hit the subgraph endpoint just once.
wheres = []
for wrapped_token in market.wrapped_tokens:
Expand Down
2 changes: 1 addition & 1 deletion prediction_market_agent_tooling/monitor/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ def monitor_market_outcome_bias(

if len(df) > 0:
st.altair_chart(
alt.layer(open_chart, resolved_chart).interactive(), # type: ignore # Doesn't expect `LayerChart`, but `Chart`, yet it works.
alt.layer(open_chart, resolved_chart).interactive(),
use_container_width=True,
)

Expand Down
12 changes: 4 additions & 8 deletions prediction_market_agent_tooling/monitor/monitor_app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import typing as t
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta

import streamlit as st

Expand Down Expand Up @@ -105,13 +105,9 @@ def monitor_app(
start_time: DatetimeUTC | None = (
DatetimeUTC.from_datetime(
datetime.combine(
t.cast(
# This will be always a date for us, so casting.
date,
st.date_input(
"Start time",
value=utcnow() - timedelta(weeks=settings.PAST_N_WEEKS),
),
st.date_input(
"Start time",
value=utcnow() - timedelta(weeks=settings.PAST_N_WEEKS),
),
datetime.min.time(),
)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "prediction-market-agent-tooling"
version = "0.57.15"
version = "0.57.16"
description = "Tools to benchmark, deploy and monitor prediction market agents."
authors = ["Gnosis"]
readme = "README.md"
Expand Down
62 changes: 62 additions & 0 deletions scripts/create_market_seer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from datetime import datetime

import typer
from web3 import Web3

from prediction_market_agent_tooling.config import APIKeys
from prediction_market_agent_tooling.gtypes import private_key_type, xdai_type, xDai
from prediction_market_agent_tooling.loggers import logger
from prediction_market_agent_tooling.markets.omen.data_models import (
OMEN_BINARY_MARKET_OUTCOMES,
)
from prediction_market_agent_tooling.markets.seer.seer import seer_create_market_tx
from prediction_market_agent_tooling.tools.utils import DatetimeUTC


def main(
question: str = typer.Option(),
opening_time: datetime = typer.Option(),
category: str = typer.Option(),
initial_funds: str = typer.Option(),
from_private_key: str = typer.Option(),
safe_address: str = typer.Option(None),
min_bond_xdai: xDai = typer.Option(xdai_type(10)),
language: str = typer.Option("en"),
Copy link
Contributor

Choose a reason for hiding this comment

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

This is en, but before, en_US default value was used. Do they accept both?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Both accepted - I default to en_US following some market examples I found (like this https://gnosisscan.io/tx/0xed488fd997d4dcec221c3e0394570dc88b14a3fa14bd7b5b8b7b305c84c4c677)

outcomes: list[str] = typer.Option(OMEN_BINARY_MARKET_OUTCOMES),
auto_deposit: bool = typer.Option(False),
) -> None:
"""
Helper script to create a market on Omen, usage:

```bash
python scripts/create_market_seer.py \
--question "Will GNO reach $500 by the end of the 2024?" \
--opening_time "2024-12-31T23:59:59" \
--category cryptocurrency \
--initial-funds 0.01 \
--from-private-key your-private-key
```
"""
safe_address_checksum = (
Web3.to_checksum_address(safe_address) if safe_address else None
)
api_keys = APIKeys(
BET_FROM_PRIVATE_KEY=private_key_type(from_private_key),
SAFE_ADDRESS=safe_address_checksum,
)
market = seer_create_market_tx(
api_keys=api_keys,
initial_funds=xdai_type(initial_funds),
question=question,
opening_time=DatetimeUTC.from_datetime(opening_time),
category=category,
language=language,
outcomes=outcomes,
auto_deposit=auto_deposit,
min_bond_xdai=min_bond_xdai,
)
logger.info(f"Market created: {market}")


if __name__ == "__main__":
typer.run(main)
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def test_get_pools_for_market(handler: SeerSubgraphHandler) -> None:
us_election_market_id = HexBytes("0x43d881f5920ed29fc5cd4917d6817496abbba6d9")
market = handler.get_market_by_id(us_election_market_id)

pools = handler.get_pools_for_market(market)
pools = handler.get_swapr_pools_for_market(market)
assert len(pools) > 1
for pool in pools:
# one of the tokens must be a wrapped token
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from web3 import Web3

from prediction_market_agent_tooling.config import APIKeys
from prediction_market_agent_tooling.markets.seer.data_models import (
CreateCategoricalMarketsParams,
)
from prediction_market_agent_tooling.markets.seer.seer_contracts import (
SeerMarketFactory,
)
from prediction_market_agent_tooling.tools.datetime_utc import DatetimeUTC


def build_params() -> CreateCategoricalMarketsParams:
return SeerMarketFactory.build_market_params(
market_question="test test test",
outcomes=["Yes", "No"],
opening_time=DatetimeUTC.now(),
language="en_US",
category="misc",
)


def test_create_market(local_web3: Web3, test_keys: APIKeys) -> None:
factory = SeerMarketFactory()
num_initial_markets = factory.market_count(web3=local_web3)
params = build_params()
tx_receipt = factory.create_categorical_market(
api_keys=test_keys, params=params, web3=local_web3
)

num_final_markets = factory.market_count(web3=local_web3)
assert num_initial_markets + 1 == num_final_markets
Loading