Express relay python searcher (#1319)

* move js searcher sdk

* add the python searcher sdk

* remove pycache

* create class for simple searcher

* add websocket to python searcher sdk

* finish ws, avoid storing liquidation opportunities within client

* python scripts now working w auto type generation

* minor precommit changes

* address comments 1

* add openapi type generations

* fixed precommit issues on generated type files

* reorg

* fixed openapi_client generated precommit

* fix js filepath issue

* added close ws

* renamings and add send_ws_message method

* get rid of duplicate error parsing

* cleanup

* set up pypi workflow

* address comments

* add python precommit

* changes + precommit changes

* test precommit change

* test precommit change

* test precommit change

* test precommit change

* test precommit change

* test precommit change

* test precommit change

* test precommit change

* test precommit change

* correct directory for poetry in pypi wf

* remove isort

* rename package

* add UUID and some cleanup

* new openapi typings

* add pydantic to pyproj

* more changes

* precommit

* remove extraneous files, stick w actual_instance

* added back http as nondefault

* correction

* some cleanup and reorg

* minor changes

* add back api response typing file

* minorer changes

* exclude openapi_client from end of file fixer

* build internal models via pydantic

* chgs

* start to reorg classes

* configure precommit to work

* some cleanup

* reorg a bit

* address comments

* chgs

* fgt

* morechgs

* some more chgs

---------

Co-authored-by: ani <ani@Anirudhs-MacBook-Pro.local>
Co-authored-by: ani <ani@Anirudhs-MBP.cable.rcn.com>
This commit is contained in:
Anirudh Suresh 2024-03-15 15:08:48 -04:00 committed by GitHub
parent 6bea697a78
commit 92566736da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 8747 additions and 5 deletions

View File

@ -5,6 +5,10 @@ on:
push:
branches: [main]
env:
PYTHON_VERSION: "3.11"
POETRY_VERSION: "1.4.2"
jobs:
pre-commit:
runs-on: ubuntu-latest
@ -13,7 +17,6 @@ jobs:
with:
# Need to grab the history of the PR
fetch-depth: 0
- uses: actions/setup-python@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
@ -24,6 +27,21 @@ jobs:
profile: minimal
toolchain: nightly-2023-07-23
components: rustfmt, clippy
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v2
with:
python-version: ${{ env.PYTHON_VERSION }}
id: setup_python
- name: Cache Poetry cache
uses: actions/cache@v3
with:
path: ~/.cache/pypoetry
key: poetry-cache-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ env.POETRY_VERSION }}
- name: Install poetry dependencies
run: poetry -C express_relay/sdk/python/express_relay install
shell: sh
- uses: pre-commit/action@v3.0.0
if: ${{ github.event_name == 'pull_request' }}
with:

View File

@ -0,0 +1,21 @@
name: Upload express-relay Python Package to PyPI
on:
push:
tags:
- "python-v*"
jobs:
deploy:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- name: Install dependencies
run: |
python3 -m pip install --upgrade poetry
poetry -C express_relay/sdk/python/express_relay build
- name: Build and publish
run: |
poetry -C express_relay/sdk/python/express_relay build
poetry -C express_relay/sdk/python/express_relay publish --username __token__ --password ${{ secrets.PYPI_TOKEN }}

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ tsconfig.tsbuildinfo
*mnemonic*
.envrc
*/*.sui.log*
__pycache__

View File

@ -111,3 +111,19 @@ repos:
entry: cargo +nightly-2023-03-01 clippy --manifest-path ./target_chains/solana/Cargo.toml --tests --fix --allow-dirty --allow-staged -- -D warnings
pass_filenames: false
files: target_chains/solana
# For express relay python files
- id: black
name: black
entry: poetry -C express_relay/sdk/python/express_relay run black express_relay/sdk/python/express_relay
pass_filenames: false
language: "system"
- id: pyflakes
name: pyflakes
entry: poetry -C express_relay/sdk/python/express_relay run pyflakes express_relay/sdk/python/express_relay
pass_filenames: false
language: "system"
- id: mypy
name: mypy
entry: poetry -C express_relay/sdk/python/express_relay run mypy express_relay/sdk/python/express_relay
pass_filenames: false
language: "system"

5667
express_relay/sdk/js/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@
"isomorphic-ws": "^5.0.0",
"openapi-client-axios": "^7.5.4",
"openapi-fetch": "^0.8.2",
"openapi-typescript": "^6.5.5",
"viem": "^2.7.6",
"ws": "^8.16.0"
},
@ -46,9 +47,8 @@
"@typescript-eslint/parser": "^5.21.0",
"eslint": "^8.56.0",
"jest": "^27.5.1",
"openapi-typescript": "^6.5.5",
"prettier": "^2.6.2",
"typescript": "^5.1",
"typescript": "^5.3.3",
"yargs": "^17.4.1"
},
"license": "Apache-2.0"

View File

@ -100,7 +100,7 @@ export class Client {
websocketEndpoint.pathname = "/v1/ws";
this.websocket = new WebSocket(websocketEndpoint.toString());
this.websocket.on("message", async (data) => {
this.websocket.on("message", async (data: string) => {
const message:
| components["schemas"]["ServerResultResponse"]
| components["schemas"]["ServerUpdateResponse"] = JSON.parse(

View File

@ -8,7 +8,8 @@
"outDir": "./lib",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"typeRoots": ["node_modules/@types"]
},
"include": ["src"],
"exclude": ["node_modules", "**/__tests__/*"]

View File

@ -0,0 +1,25 @@
# Express Relay Python SDK
Utility library for searchers and protocols to interact with the Express Relay API.
The SDK includes searcher-side utilities and protocol-side utilities. The searcher-side utilities include a basic Searcher client for connecting to the Express Relay server as well as an example SimpleSearcher class that provides a simple workflow for assessing and bidding on liquidation opportunities.
# Searcher
## Installation
### poetry
```
$ poetry add express-relay
```
## Quickstart
To run the simple searcher script, navigate to `python/` and run
```
$ python3 -m express_relay.searcher.examples.simple_searcher --private-key <PRIVATE_KEY_HEX_STRING> --chain-id development --verbose --server-url https://per-staging.dourolabs.app/
```
This simple example runs a searcher that queries the Express Relay liquidation server for available liquidation opportunities and naively submits a bid on each available opportunity.

View File

@ -0,0 +1,442 @@
import asyncio
from asyncio import Task
import json
import urllib.parse
from typing import Callable, Any
from collections.abc import Coroutine
from uuid import UUID
import httpx
import web3
import websockets
from websockets.client import WebSocketClientProtocol
from eth_abi import encode
from eth_account.account import Account
from web3.auto import w3
from express_relay.types import (
Opportunity,
BidStatusUpdate,
ClientMessage,
BidStatus,
Bid,
OpportunityBid,
OpportunityParams,
)
class ExpressRelayClientException(Exception):
pass
class ExpressRelayClient:
def __init__(
self,
server_url: str,
opportunity_callback: (
Callable[[Opportunity], Coroutine[Any, Any, Any]] | None
) = None,
bid_status_callback: (
Callable[[BidStatusUpdate], Coroutine[Any, Any, Any]] | None
) = None,
timeout_response_secs: int = 10,
ws_options: dict[str, Any] | None = None,
http_options: dict[str, Any] | None = None,
):
"""
Args:
server_url: The URL of the auction server.
opportunity_callback: An async function that serves as the callback on a new opportunity. Should take in one external argument of type Opportunity.
bid_status_callback: An async function that serves as the callback on a new bid status update. Should take in one external argument of type BidStatusUpdate.
timeout_response_secs: The number of seconds to wait for a response message from the server.
ws_options: Keyword arguments to pass to the websocket connection.
http_options: Keyword arguments to pass to the HTTP client.
"""
parsed_url = urllib.parse.urlparse(server_url)
if parsed_url.scheme == "https":
ws_scheme = "wss"
elif parsed_url.scheme == "http":
ws_scheme = "ws"
else:
raise ValueError("Invalid server URL")
self.server_url = server_url
self.ws_endpoint = parsed_url._replace(scheme=ws_scheme, path="/v1/ws").geturl()
self.ws_msg_counter = 0
self.ws: WebSocketClientProtocol
self.ws_lock = asyncio.Lock()
self.ws_loop: Task[Any]
self.ws_msg_futures: dict[str, asyncio.Future] = {}
self.timeout_response_secs = timeout_response_secs
if ws_options is None:
ws_options = {}
self.ws_options = ws_options
if http_options is None:
http_options = {}
self.http_options = http_options
self.opportunity_callback = opportunity_callback
self.bid_status_callback = bid_status_callback
async def start_ws(self):
"""
Initializes the websocket connection to the server, if not already connected.
"""
async with self.ws_lock:
if not hasattr(self, "ws"):
self.ws = await websockets.connect(self.ws_endpoint, **self.ws_options)
if not hasattr(self, "ws_loop"):
ws_call = self.ws_handler(
self.opportunity_callback, self.bid_status_callback
)
self.ws_loop = asyncio.create_task(ws_call)
async def close_ws(self):
"""
Closes the websocket connection to the server.
"""
async with self.ws_lock:
await self.ws.close()
async def get_ws_loop(self) -> asyncio.Task:
"""
Returns the websocket handler loop.
"""
await self.start_ws()
return self.ws_loop
def convert_client_msg_to_server(self, client_msg: ClientMessage) -> dict:
"""
Converts the params of a ClientMessage model dict to the format expected by the server.
Args:
client_msg: The message to send to the server.
Returns:
The message as a dict with the params converted to the format expected by the server.
"""
msg = client_msg.model_dump()
method = msg["params"]["method"]
msg["id"] = str(self.ws_msg_counter)
self.ws_msg_counter += 1
if method == "post_bid":
params = {
"bid": {
"amount": msg["params"]["amount"],
"target_contract": msg["params"]["target_contract"],
"chain_id": msg["params"]["chain_id"],
"target_calldata": msg["params"]["target_calldata"],
"permission_key": msg["params"]["permission_key"],
}
}
msg["params"] = params
elif method == "post_opportunity_bid":
params = {
"opportunity_id": msg["params"]["opportunity_id"],
"opportunity_bid": {
"amount": msg["params"]["amount"],
"executor": msg["params"]["executor"],
"permission_key": msg["params"]["permission_key"],
"signature": msg["params"]["signature"],
"valid_until": msg["params"]["valid_until"],
},
}
msg["params"] = params
msg["method"] = method
return msg
async def send_ws_msg(self, client_msg: ClientMessage) -> dict:
"""
Sends a message to the server via websocket.
Args:
client_msg: The message to send.
Returns:
The result of the response message from the server.
"""
await self.start_ws()
msg = self.convert_client_msg_to_server(client_msg)
future = asyncio.get_event_loop().create_future()
self.ws_msg_futures[msg["id"]] = future
await self.ws.send(json.dumps(msg))
# await the response for the sent ws message from the server
msg_response = await asyncio.wait_for(
future, timeout=self.timeout_response_secs
)
return self.process_response_msg(msg_response)
def process_response_msg(self, msg: dict) -> dict:
"""
Processes a response message received from the server via websocket.
Args:
msg: The message to process.
Returns:
The result field of the message.
"""
if msg.get("status") and msg.get("status") != "success":
raise ExpressRelayClientException(
f"Error in websocket response with message id {msg.get('id')}: {msg.get('result')}"
)
return msg["result"]
async def subscribe_chains(self, chain_ids: list[str]):
"""
Subscribes websocket to a list of chain IDs for new opportunities.
Args:
chain_ids: A list of chain IDs to subscribe to.
"""
params = {
"method": "subscribe",
"chain_ids": chain_ids,
}
client_msg = ClientMessage.model_validate({"params": params})
await self.send_ws_msg(client_msg)
async def unsubscribe_chains(self, chain_ids: list[str]):
"""
Unsubscribes websocket from a list of chain IDs for new opportunities.
Args:
chain_ids: A list of chain IDs to unsubscribe from.
"""
params = {
"method": "unsubscribe",
"chain_ids": chain_ids,
}
client_msg = ClientMessage.model_validate({"params": params})
await self.send_ws_msg(client_msg)
async def submit_bid(self, bid: Bid, subscribe_to_updates: bool = True) -> UUID:
"""
Submits a bid to the auction server.
Args:
bid: An object representing the bid to submit.
subscribe_to_updates: A boolean indicating whether to subscribe to the bid status updates.
Returns:
The ID of the submitted bid.
"""
bid_dict = bid.model_dump()
if subscribe_to_updates:
bid_dict["method"] = "post_bid"
client_msg = ClientMessage.model_validate({"params": bid_dict})
result = await self.send_ws_msg(client_msg)
bid_id = UUID(result.get("id"))
else:
async with httpx.AsyncClient(**self.http_options) as client:
resp = await client.post(
urllib.parse.urlparse(self.server_url)
._replace(path="/v1/bids")
.geturl(),
json=bid_dict,
)
resp.raise_for_status()
bid_id = UUID(resp.json().get("id"))
return bid_id
async def submit_opportunity_bid(
self,
opportunity_bid: OpportunityBid,
subscribe_to_updates: bool = True,
) -> UUID:
"""
Submits a bid on an opportunity to the server via websocket.
Args:
opportunity_bid: An object representing the bid to submit on an opportunity.
subscribe_to_updates: A boolean indicating whether to subscribe to the bid status updates.
Returns:
The ID of the submitted bid.
"""
opportunity_bid_dict = opportunity_bid.model_dump()
if subscribe_to_updates:
params = {
"method": "post_opportunity_bid",
"opportunity_id": opportunity_bid.opportunity_id,
"amount": opportunity_bid.amount,
"executor": opportunity_bid.executor,
"permission_key": opportunity_bid.permission_key,
"signature": opportunity_bid.signature,
"valid_until": opportunity_bid.valid_until,
}
client_msg = ClientMessage.model_validate({"params": params})
result = await self.send_ws_msg(client_msg)
bid_id = UUID(result.get("id"))
else:
async with httpx.AsyncClient(**self.http_options) as client:
resp = await client.post(
urllib.parse.urlparse(self.server_url)
._replace(
path=f"/v1/opportunities/{opportunity_bid.opportunity_id}/bids"
)
.geturl(),
json=opportunity_bid_dict,
)
resp.raise_for_status()
bid_id = UUID(resp.json().get("id"))
return bid_id
async def ws_handler(
self,
opportunity_callback: (
Callable[[Opportunity], Coroutine[Any, Any, Any]] | None
) = None,
bid_status_callback: (
Callable[[BidStatusUpdate], Coroutine[Any, Any, Any]] | None
) = None,
):
"""
Continually handles new ws messages as they are received from the server via websocket.
Args:
opportunity_callback: An async function that serves as the callback on a new opportunity. Should take in one external argument of type Opportunity.
bid_status_callback: An async function that serves as the callback on a new bid status update. Should take in one external argument of type BidStatusUpdate.
"""
if not self.ws:
raise ExpressRelayClientException("Websocket not connected")
async for msg in self.ws:
msg_json = json.loads(msg)
if msg_json.get("type"):
if msg_json.get("type") == "new_opportunity":
if opportunity_callback is not None:
opportunity = Opportunity.process_opportunity_dict(
msg_json["opportunity"]
)
if opportunity:
asyncio.create_task(opportunity_callback(opportunity))
elif msg_json.get("type") == "bid_status_update":
if bid_status_callback is not None:
id = msg_json["status"]["id"]
bid_status = msg_json["status"]["bid_status"]["status"]
result = msg_json["status"]["bid_status"].get("result")
bid_status_update = BidStatusUpdate(
id=id, bid_status=BidStatus(bid_status), result=result
)
asyncio.create_task(bid_status_callback(bid_status_update))
elif msg_json.get("id"):
future = self.ws_msg_futures.pop(msg_json["id"])
future.set_result(msg_json)
async def get_opportunities(self, chain_id: str | None = None) -> list[Opportunity]:
"""
Connects to the server and fetches opportunities.
Args:
chain_id: The chain ID to fetch opportunities for. If None, fetches opportunities across all chains.
Returns:
A list of opportunities.
"""
params = {}
if chain_id:
params["chain_id"] = chain_id
async with httpx.AsyncClient(**self.http_options) as client:
resp = await client.get(
urllib.parse.urlparse(self.server_url)
._replace(path="/v1/opportunities")
.geturl(),
params=params,
)
resp.raise_for_status()
opportunities = []
for opportunity in resp.json():
opportunity_processed = Opportunity.process_opportunity_dict(opportunity)
if opportunity_processed:
opportunities.append(opportunity_processed)
return opportunities
async def submit_opportunity(self, opportunity: OpportunityParams) -> UUID:
"""
Submits an opportunity to the server.
Args:
opportunity: An object representing the opportunity to submit.
Returns:
The ID of the submitted opportunity.
"""
async with httpx.AsyncClient(**self.http_options) as client:
resp = await client.post(
urllib.parse.urlparse(self.server_url)
._replace(path="/v1/opportunities")
.geturl(),
json=opportunity.params.model_dump(),
)
resp.raise_for_status()
return UUID(resp.json()["opportunity_id"])
def sign_bid(
opportunity: Opportunity,
bid_amount: int,
valid_until: int,
private_key: str,
) -> OpportunityBid:
"""
Constructs a signature for a searcher's bid and returns the OpportunityBid object to be submitted to the server.
Args:
opportunity: An object representing the opportunity, of type Opportunity.
bid_amount: An integer representing the amount of the bid (in wei).
valid_until: An integer representing the unix timestamp until which the bid is valid.
private_key: A 0x-prefixed hex string representing the searcher's private key.
Returns:
A OpportunityBid object, representing the transaction to submit to the server. This object contains the searcher's signature.
"""
sell_tokens = [
(token.token, int(token.amount)) for token in opportunity.sell_tokens
]
buy_tokens = [(token.token, int(token.amount)) for token in opportunity.buy_tokens]
target_calldata = bytes.fromhex(opportunity.target_calldata.replace("0x", ""))
digest = encode(
[
"(address,uint256)[]",
"(address,uint256)[]",
"address",
"bytes",
"uint256",
"uint256",
"uint256",
],
[
sell_tokens,
buy_tokens,
opportunity.target_contract,
target_calldata,
opportunity.target_call_value,
bid_amount,
valid_until,
],
)
msg_data = web3.Web3.solidity_keccak(["bytes"], [digest])
signature = w3.eth.account.signHash(msg_data, private_key=private_key)
opportunity_bid = OpportunityBid(
opportunity_id=opportunity.opportunity_id,
permission_key=opportunity.permission_key,
amount=bid_amount,
valid_until=valid_until,
executor=Account.from_key(private_key).address,
signature=signature,
)
return opportunity_bid

View File

@ -0,0 +1,132 @@
import argparse
import asyncio
import logging
from eth_account.account import Account
from express_relay.client import ExpressRelayClient, sign_bid
from express_relay.types import (
Opportunity,
OpportunityBid,
Bytes32,
BidStatus,
BidStatusUpdate,
)
logger = logging.getLogger(__name__)
NAIVE_BID = 10
# Set validity (naively) to max uint256
VALID_UNTIL_MAX = 2**256 - 1
class SimpleSearcher:
def __init__(self, server_url: str, private_key: Bytes32):
self.client = ExpressRelayClient(
server_url, self.opportunity_callback, self.bid_status_callback
)
self.private_key = private_key
self.public_key = Account.from_key(private_key).address
def assess_opportunity(
self,
opp: Opportunity,
) -> OpportunityBid | None:
"""
Assesses whether an opportunity is worth executing; if so, returns an OpportunityBid object. Otherwise returns None.
This function determines whether the given opportunity is worthwhile to execute.
There are many ways to evaluate this, but the most common way is to check that the value of the tokens the searcher will receive from execution exceeds the value of the tokens spent.
Individual searchers will have their own methods to determine market impact and the profitability of executing an opportunity. This function can use external prices to perform this evaluation.
In this simple searcher, the function always (naively) returns an OpportunityBid object with a default bid and valid_until timestamp.
Args:
opp: An object representing a single opportunity.
Returns:
If the opportunity is deemed worthwhile, this function can return an OpportunityBid object, whose contents can be submitted to the auction server. If the opportunity is not deemed worthwhile, this function can return None.
"""
opportunity_bid = sign_bid(opp, NAIVE_BID, VALID_UNTIL_MAX, self.private_key)
return opportunity_bid
async def opportunity_callback(self, opp: Opportunity):
"""
Callback function to run when a new opportunity is found.
Args:
opp: An object representing a single opportunity.
"""
opportunity_bid = self.assess_opportunity(opp)
if opportunity_bid:
try:
await self.client.submit_opportunity_bid(opportunity_bid)
logger.info(
f"Submitted bid amount {opportunity_bid.amount} for opportunity {str(opportunity_bid.opportunity_id)}"
)
except Exception as e:
logger.error(
f"Error submitting bid amount {opportunity_bid.amount} for opportunity {str(opportunity_bid.opportunity_id)}: {e}"
)
async def bid_status_callback(self, bid_status_update: BidStatusUpdate):
"""
Callback function to run when a bid status is updated.
Args:
bid_status_update: An object representing an update to the status of a bid.
"""
id = bid_status_update.id
bid_status = bid_status_update.bid_status
result = bid_status_update.result
if bid_status == BidStatus("submitted"):
logger.info(f"Bid {id} has been submitted in hash {result}")
elif bid_status == BidStatus("lost"):
logger.info(f"Bid {id} was unsuccessful")
elif bid_status == BidStatus("pending"):
logger.info(f"Bid {id} is pending")
else:
logger.error(f"Unrecognized status {bid_status} for bid {id}")
async def main():
parser = argparse.ArgumentParser()
parser.add_argument("-v", "--verbose", action="count", default=0)
parser.add_argument(
"--private-key",
type=str,
required=True,
help="Private key of the searcher for signing calldata as a hex string",
)
parser.add_argument(
"--chain-ids",
type=str,
required=True,
nargs="+",
help="Chain ID(s) of the network(s) to monitor for opportunities",
)
parser.add_argument(
"--server-url",
type=str,
required=True,
help="Server endpoint to use for fetching opportunities and submitting bids",
)
args = parser.parse_args()
logger.setLevel(logging.INFO if args.verbose == 0 else logging.DEBUG)
log_handler = logging.StreamHandler()
formatter = logging.Formatter(
"%(asctime)s %(levelname)s:%(name)s:%(module)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log_handler.setFormatter(formatter)
logger.addHandler(log_handler)
simple_searcher = SimpleSearcher(args.server_url, args.private_key)
logger.info("Searcher address: %s", simple_searcher.public_key)
await simple_searcher.client.subscribe_chains(args.chain_ids)
task = await simple_searcher.client.get_ws_loop()
await task
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,318 @@
from enum import Enum
from pydantic import BaseModel, model_validator
from pydantic.functional_validators import AfterValidator
from pydantic.functional_serializers import PlainSerializer
from uuid import UUID
import web3
from typing import Union, ClassVar
from pydantic import Field
from typing_extensions import Literal, Annotated
import warnings
import string
from eth_account.datastructures import SignedMessage
class UnsupportedOpportunityVersionException(Exception):
pass
def check_hex_string(s: str):
"""
Validates that a string is a valid hex string.
Args:
s: The string to validate as a hex string. Can be '0x'-prefixed.
"""
ind = 0
if s.startswith("0x"):
ind = 2
assert all(
c in string.hexdigits for c in s[ind:]
), "string is not a valid hex string"
return s
def check_bytes32(s: str):
"""
Validates that a string is a valid 32-byte hex string.
Args:
s: The string to validate as a 32-byte hex string. Can be '0x'-prefixed.
"""
check_hex_string(s)
ind = 0
if s.startswith("0x"):
ind = 2
assert len(s[ind:]) == 64, "hex string is not 32 bytes long"
return s
def check_address(s: str):
"""
Validates that a string is a valid Ethereum address.
Args:
s: The string to validate as an Ethereum address. Can be '0x'-prefixed.
"""
assert web3.Web3.is_address(s), "string is not a valid Ethereum address"
return s
HexString = Annotated[str, AfterValidator(check_hex_string)]
Bytes32 = Annotated[str, AfterValidator(check_bytes32)]
Address = Annotated[str, AfterValidator(check_address)]
IntString = Annotated[int, PlainSerializer(lambda x: str(x), return_type=str)]
UUIDString = Annotated[UUID, PlainSerializer(lambda x: str(x), return_type=str)]
SignedMessageString = Annotated[
SignedMessage, PlainSerializer(lambda x: bytes(x.signature).hex(), return_type=str)
]
class TokenAmount(BaseModel):
"""
Attributes:
token: The address of the token contract.
amount: The amount of the token.
"""
token: Address
amount: IntString
class Bid(BaseModel):
"""
Attributes:
amount: The amount of the bid in wei.
target_calldata: The calldata for the contract call.
chain_id: The chain ID to bid on.
target_contract: The contract address to call.
permission_key: The permission key to bid on.
"""
amount: IntString
target_calldata: HexString
chain_id: str
target_contract: Address
permission_key: HexString
class BidStatus(Enum):
SUBMITTED = "submitted"
LOST = "lost"
PENDING = "pending"
class BidStatusUpdate(BaseModel):
"""
Attributes:
id: The ID of the bid.
bid_status: The status enum, either SUBMITTED, LOST, or PENDING.
result: The result of the bid: a transaction hash if the status is SUBMITTED, else None.
"""
id: UUIDString
bid_status: BidStatus
result: Bytes32 | None = Field(default=None)
@model_validator(mode="after")
def check_result(self):
if self.bid_status == BidStatus("submitted"):
assert self.result is not None, "result must be a valid 32-byte hash"
else:
assert self.result is None, "result must be None"
return self
class OpportunityBid(BaseModel):
"""
Attributes:
opportunity_id: The ID of the opportunity.
amount: The amount of the bid in wei.
executor: The address of the executor.
permission_key: The permission key to bid on.
signature: The signature of the bid.
valid_until: The unix timestamp after which the bid becomes invalid.
"""
opportunity_id: UUIDString
amount: IntString
executor: Address
permission_key: HexString
signature: SignedMessageString
valid_until: IntString
model_config = {
"arbitrary_types_allowed": True,
}
class OpportunityParamsV1(BaseModel):
"""
Attributes:
target_calldata: The calldata for the contract call.
chain_id: The chain ID to bid on.
target_contract: The contract address to call.
permission_key: The permission key to bid on.
buy_tokens: The tokens to receive in the opportunity.
sell_tokens: The tokens to spend in the opportunity.
target_call_value: The value to send with the contract call.
version: The version of the opportunity.
"""
target_calldata: HexString
chain_id: str
target_contract: Address
permission_key: HexString
buy_tokens: list[TokenAmount]
sell_tokens: list[TokenAmount]
target_call_value: IntString
version: Literal["v1"]
class OpportunityParams(BaseModel):
"""
Attributes:
params: The parameters of the opportunity.
"""
params: Union[OpportunityParamsV1] = Field(..., discriminator="version")
class Opportunity(BaseModel):
"""
Attributes:
target_calldata: The calldata for the contract call.
chain_id: The chain ID to bid on.
target_contract: The contract address to call.
permission_key: The permission key to bid on.
buy_tokens: The tokens to receive in the opportunity.
sell_tokens: The tokens to spend in the opportunity.
target_call_value: The value to send with the contract call.
version: The version of the opportunity.
creation_time: The creation time of the opportunity.
opportunity_id: The ID of the opportunity.
"""
target_calldata: HexString
chain_id: str
target_contract: Address
permission_key: HexString
buy_tokens: list[TokenAmount]
sell_tokens: list[TokenAmount]
target_call_value: IntString
version: str
creation_time: IntString
opportunity_id: UUIDString
supported_versions: ClassVar[list[str]] = ["v1"]
@model_validator(mode="before")
@classmethod
def check_version(cls, data):
if data["version"] not in cls.supported_versions:
raise UnsupportedOpportunityVersionException(
f"Cannot handle opportunity version: {data['version']}. Please upgrade your client."
)
return data
@classmethod
def process_opportunity_dict(cls, opportunity_dict: dict):
"""
Processes an opportunity dictionary and converts to a class object.
Args:
opportunity_dict: The opportunity dictionary to convert.
Returns:
The opportunity as a class object.
"""
try:
return cls.model_validate(opportunity_dict)
except UnsupportedOpportunityVersionException as e:
warnings.warn(str(e))
return None
class SubscribeMessageParams(BaseModel):
"""
Attributes:
method: A string literal "subscribe".
chain_ids: The chain IDs to subscribe to.
"""
method: Literal["subscribe"]
chain_ids: list[str]
class UnsubscribeMessageParams(BaseModel):
"""
Attributes:
method: A string literal "unsubscribe".
chain_ids: The chain IDs to subscribe to.
"""
method: Literal["unsubscribe"]
chain_ids: list[str]
class PostBidMessageParams(BaseModel):
"""
Attributes:
method: A string literal "post_bid".
amount: The amount of the bid in wei.
target_calldata: The calldata for the contract call.
chain_id: The chain ID to bid on.
target_contract: The contract address to call.
permission_key: The permission key to bid on.
"""
method: Literal["post_bid"]
amount: IntString
target_calldata: HexString
chain_id: str
target_contract: Address
permission_key: HexString
class PostOpportunityBidMessageParams(BaseModel):
"""
Attributes:
method: A string literal "post_opportunity_bid".
opportunity_id: The ID of the opportunity.
amount: The amount of the bid in wei.
executor: The address of the executor.
permission_key: The permission key to bid on.
signature: The signature of the bid.
valid_until: The unix timestamp after which the bid becomes invalid.
"""
method: Literal["post_opportunity_bid"]
opportunity_id: UUIDString
amount: IntString
executor: Address
permission_key: HexString
signature: SignedMessageString
valid_until: IntString
model_config = {
"arbitrary_types_allowed": True,
}
class ClientMessage(BaseModel):
"""
Attributes:
params: The parameters of the message.
"""
params: Union[
SubscribeMessageParams,
UnsubscribeMessageParams,
PostBidMessageParams,
PostOpportunityBidMessageParams,
] = Field(..., discriminator="method")

View File

@ -0,0 +1,14 @@
[mypy]
plugins = pydantic.mypy
follow_imports = silent
warn_redundant_casts = True
warn_unused_ignores = True
disallow_any_generics = True
check_untyped_defs = True
no_implicit_reexport = True
[pydantic-mypy]
init_forbid_extra = True
init_typed = True
warn_required_dynamic_aliases = True

2060
express_relay/sdk/python/poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
[tool.poetry]
name = "express-relay"
version = "0.2.0"
description = "Utilities for searchers and protocols to interact with the Express Relay protocol."
authors = ["dourolabs"]
license = "Proprietary"
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
web3 = "^6.15.1"
eth_abi = "^4.2.1"
eth_account = "^0.10.0"
httpx = "^0.23.3"
websockets = "^11.0.3"
asyncio = "^3.4.3"
argparse = "^1.4.0"
pydantic = "^2.6.3"
[tool.poetry.group.dev.dependencies]
black = "^24.1.1"
pyflakes = "^3.2.0"
mypy = "^1.9.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"