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:
parent
6bea697a78
commit
92566736da
|
@ -5,6 +5,10 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: "3.11"
|
||||||
|
POETRY_VERSION: "1.4.2"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
pre-commit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -13,7 +17,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
# Need to grab the history of the PR
|
# Need to grab the history of the PR
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-python@v2
|
|
||||||
- uses: actions-rs/toolchain@v1
|
- uses: actions-rs/toolchain@v1
|
||||||
with:
|
with:
|
||||||
profile: minimal
|
profile: minimal
|
||||||
|
@ -24,6 +27,21 @@ jobs:
|
||||||
profile: minimal
|
profile: minimal
|
||||||
toolchain: nightly-2023-07-23
|
toolchain: nightly-2023-07-23
|
||||||
components: rustfmt, clippy
|
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
|
- uses: pre-commit/action@v3.0.0
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
with:
|
with:
|
||||||
|
|
|
@ -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 }}
|
|
@ -18,3 +18,4 @@ tsconfig.tsbuildinfo
|
||||||
*mnemonic*
|
*mnemonic*
|
||||||
.envrc
|
.envrc
|
||||||
*/*.sui.log*
|
*/*.sui.log*
|
||||||
|
__pycache__
|
||||||
|
|
|
@ -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
|
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
|
pass_filenames: false
|
||||||
files: target_chains/solana
|
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"
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -37,6 +37,7 @@
|
||||||
"isomorphic-ws": "^5.0.0",
|
"isomorphic-ws": "^5.0.0",
|
||||||
"openapi-client-axios": "^7.5.4",
|
"openapi-client-axios": "^7.5.4",
|
||||||
"openapi-fetch": "^0.8.2",
|
"openapi-fetch": "^0.8.2",
|
||||||
|
"openapi-typescript": "^6.5.5",
|
||||||
"viem": "^2.7.6",
|
"viem": "^2.7.6",
|
||||||
"ws": "^8.16.0"
|
"ws": "^8.16.0"
|
||||||
},
|
},
|
||||||
|
@ -46,9 +47,8 @@
|
||||||
"@typescript-eslint/parser": "^5.21.0",
|
"@typescript-eslint/parser": "^5.21.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"jest": "^27.5.1",
|
"jest": "^27.5.1",
|
||||||
"openapi-typescript": "^6.5.5",
|
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^2.6.2",
|
||||||
"typescript": "^5.1",
|
"typescript": "^5.3.3",
|
||||||
"yargs": "^17.4.1"
|
"yargs": "^17.4.1"
|
||||||
},
|
},
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
|
|
|
@ -100,7 +100,7 @@ export class Client {
|
||||||
websocketEndpoint.pathname = "/v1/ws";
|
websocketEndpoint.pathname = "/v1/ws";
|
||||||
|
|
||||||
this.websocket = new WebSocket(websocketEndpoint.toString());
|
this.websocket = new WebSocket(websocketEndpoint.toString());
|
||||||
this.websocket.on("message", async (data) => {
|
this.websocket.on("message", async (data: string) => {
|
||||||
const message:
|
const message:
|
||||||
| components["schemas"]["ServerResultResponse"]
|
| components["schemas"]["ServerResultResponse"]
|
||||||
| components["schemas"]["ServerUpdateResponse"] = JSON.parse(
|
| components["schemas"]["ServerUpdateResponse"] = JSON.parse(
|
||||||
|
|
|
@ -8,7 +8,8 @@
|
||||||
"outDir": "./lib",
|
"outDir": "./lib",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true,
|
||||||
|
"typeRoots": ["node_modules/@types"]
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "**/__tests__/*"]
|
"exclude": ["node_modules", "**/__tests__/*"]
|
||||||
|
|
|
@ -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.
|
|
@ -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
|
|
@ -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())
|
|
@ -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")
|
|
@ -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
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
Loading…
Reference in New Issue