odos-py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odos_py/__init__.py +33 -0
- odos_py/_base.py +110 -0
- odos_py/async_client.py +163 -0
- odos_py/client.py +167 -0
- odos_py/exceptions.py +42 -0
- odos_py/models.py +106 -0
- odos_py/py.typed +0 -0
- odos_py-0.1.0.dist-info/METADATA +148 -0
- odos_py-0.1.0.dist-info/RECORD +11 -0
- odos_py-0.1.0.dist-info/WHEEL +4 -0
- odos_py-0.1.0.dist-info/licenses/LICENSE +21 -0
odos_py/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""odos-py: a modern Python client for the Odos DEX Aggregator API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .async_client import AsyncOdosClient
|
|
6
|
+
from .client import OdosClient
|
|
7
|
+
from .exceptions import OdosAPIError, OdosError, OdosRateLimitError
|
|
8
|
+
from .models import (
|
|
9
|
+
AssembleRequest,
|
|
10
|
+
AssembleResponse,
|
|
11
|
+
InputToken,
|
|
12
|
+
OutputToken,
|
|
13
|
+
QuoteRequest,
|
|
14
|
+
QuoteResponse,
|
|
15
|
+
Transaction,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"OdosClient",
|
|
22
|
+
"AsyncOdosClient",
|
|
23
|
+
"OdosError",
|
|
24
|
+
"OdosAPIError",
|
|
25
|
+
"OdosRateLimitError",
|
|
26
|
+
"QuoteRequest",
|
|
27
|
+
"QuoteResponse",
|
|
28
|
+
"AssembleRequest",
|
|
29
|
+
"AssembleResponse",
|
|
30
|
+
"InputToken",
|
|
31
|
+
"OutputToken",
|
|
32
|
+
"Transaction",
|
|
33
|
+
]
|
odos_py/_base.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Shared configuration and HTTP plumbing for the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from email.utils import parsedate_to_datetime
|
|
7
|
+
from typing import Any, Mapping, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE_URL = "https://api.odos.xyz"
|
|
12
|
+
DEFAULT_API_KEY_HEADER = "x-api-key"
|
|
13
|
+
DEFAULT_MAX_RETRIES = 3
|
|
14
|
+
DEFAULT_BACKOFF_BASE = 0.5
|
|
15
|
+
DEFAULT_TIMEOUT = 30.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OdosConfig:
|
|
19
|
+
"""Holds connection settings shared by both client flavours."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
25
|
+
api_key: Optional[str] = None,
|
|
26
|
+
api_key_header: str = DEFAULT_API_KEY_HEADER,
|
|
27
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
28
|
+
backoff_base: float = DEFAULT_BACKOFF_BASE,
|
|
29
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.base_url = base_url.rstrip("/")
|
|
32
|
+
self.api_key = api_key
|
|
33
|
+
self.api_key_header = api_key_header
|
|
34
|
+
self.max_retries = max_retries
|
|
35
|
+
self.backoff_base = backoff_base
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
|
|
38
|
+
def headers(self) -> dict[str, str]:
|
|
39
|
+
"""Build default request headers, including the API key when set."""
|
|
40
|
+
headers = {
|
|
41
|
+
"Accept": "application/json",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
}
|
|
44
|
+
if self.api_key:
|
|
45
|
+
headers[self.api_key_header] = self.api_key
|
|
46
|
+
return headers
|
|
47
|
+
|
|
48
|
+
def url(self, path: str) -> str:
|
|
49
|
+
return f"{self.base_url}/{path.lstrip('/')}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_retry_after(
|
|
53
|
+
headers: Mapping[str, str], *, now: Optional[datetime] = None
|
|
54
|
+
) -> Optional[float]:
|
|
55
|
+
"""Extract the ``Retry-After`` delay in seconds, if present.
|
|
56
|
+
|
|
57
|
+
Per RFC 7231 the header may be either a number of seconds (delta) or an
|
|
58
|
+
HTTP-date. Both forms are supported; an HTTP-date is converted to a delay
|
|
59
|
+
relative to ``now`` (defaulting to the current UTC time) and clamped to a
|
|
60
|
+
non-negative value. Returns ``None`` when the header is absent or
|
|
61
|
+
unparseable, so callers fall back to exponential backoff.
|
|
62
|
+
"""
|
|
63
|
+
value = headers.get("Retry-After") or headers.get("retry-after")
|
|
64
|
+
if value is None:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
text = str(value).strip()
|
|
68
|
+
|
|
69
|
+
# Form 1: a number of seconds.
|
|
70
|
+
try:
|
|
71
|
+
return float(text)
|
|
72
|
+
except ValueError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# Form 2: an HTTP-date.
|
|
76
|
+
try:
|
|
77
|
+
target = parsedate_to_datetime(text)
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
return None
|
|
80
|
+
if target is None:
|
|
81
|
+
return None
|
|
82
|
+
if target.tzinfo is None:
|
|
83
|
+
target = target.replace(tzinfo=timezone.utc)
|
|
84
|
+
|
|
85
|
+
current = now or datetime.now(timezone.utc)
|
|
86
|
+
if current.tzinfo is None:
|
|
87
|
+
current = current.replace(tzinfo=timezone.utc)
|
|
88
|
+
|
|
89
|
+
delay = (target - current).total_seconds()
|
|
90
|
+
return max(0.0, delay)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def backoff_delay(base: float, attempt: int, retry_after: Optional[float]) -> float:
|
|
94
|
+
"""Compute the wait before the next retry (exponential, server-hint aware).
|
|
95
|
+
|
|
96
|
+
``attempt`` is zero-based. A server-provided ``Retry-After`` wins when it is
|
|
97
|
+
larger than the computed exponential backoff.
|
|
98
|
+
"""
|
|
99
|
+
computed = base * float(2**attempt)
|
|
100
|
+
if retry_after is not None:
|
|
101
|
+
return max(computed, retry_after)
|
|
102
|
+
return computed
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def safe_json(response: httpx.Response) -> Any:
|
|
106
|
+
"""Return parsed JSON, or the raw text if the body is not valid JSON."""
|
|
107
|
+
try:
|
|
108
|
+
return response.json()
|
|
109
|
+
except ValueError:
|
|
110
|
+
return response.text
|
odos_py/async_client.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Asynchronous client for the Odos DEX Aggregator API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._base import (
|
|
12
|
+
DEFAULT_API_KEY_HEADER,
|
|
13
|
+
DEFAULT_BACKOFF_BASE,
|
|
14
|
+
DEFAULT_BASE_URL,
|
|
15
|
+
DEFAULT_MAX_RETRIES,
|
|
16
|
+
DEFAULT_TIMEOUT,
|
|
17
|
+
OdosConfig,
|
|
18
|
+
backoff_delay,
|
|
19
|
+
parse_retry_after,
|
|
20
|
+
safe_json,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import OdosAPIError, OdosRateLimitError
|
|
23
|
+
from .models import AssembleRequest, AssembleResponse, QuoteRequest, QuoteResponse
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsyncOdosClient:
|
|
27
|
+
"""Async counterpart of :class:`~odos_py.client.OdosClient`.
|
|
28
|
+
|
|
29
|
+
Same configuration and behaviour, backed by ``httpx.AsyncClient``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
36
|
+
api_key: Optional[str] = None,
|
|
37
|
+
api_key_header: str = DEFAULT_API_KEY_HEADER,
|
|
38
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
39
|
+
backoff_base: float = DEFAULT_BACKOFF_BASE,
|
|
40
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
41
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._config = OdosConfig(
|
|
44
|
+
base_url=base_url,
|
|
45
|
+
api_key=api_key,
|
|
46
|
+
api_key_header=api_key_header,
|
|
47
|
+
max_retries=max_retries,
|
|
48
|
+
backoff_base=backoff_base,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
)
|
|
51
|
+
self._client = httpx.AsyncClient(
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
headers=self._config.headers(),
|
|
54
|
+
transport=transport,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def base_url(self) -> str:
|
|
59
|
+
return self._config.base_url
|
|
60
|
+
|
|
61
|
+
# -- lifecycle ---------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
async def aclose(self) -> None:
|
|
64
|
+
await self._client.aclose()
|
|
65
|
+
|
|
66
|
+
async def __aenter__(self) -> AsyncOdosClient:
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
async def __aexit__(
|
|
70
|
+
self,
|
|
71
|
+
exc_type: Optional[type[BaseException]],
|
|
72
|
+
exc: Optional[BaseException],
|
|
73
|
+
tb: Optional[TracebackType],
|
|
74
|
+
) -> None:
|
|
75
|
+
await self.aclose()
|
|
76
|
+
|
|
77
|
+
# -- low-level request with retry/backoff ------------------------------
|
|
78
|
+
|
|
79
|
+
async def _request(self, method: str, path: str, *, json: Any = None) -> Any:
|
|
80
|
+
url = self._config.url(path)
|
|
81
|
+
attempt = 0
|
|
82
|
+
while True:
|
|
83
|
+
response = await self._client.request(method, url, json=json)
|
|
84
|
+
if response.status_code < 400:
|
|
85
|
+
return safe_json(response)
|
|
86
|
+
|
|
87
|
+
if response.status_code == 429 and attempt < self._config.max_retries:
|
|
88
|
+
retry_after = parse_retry_after(response.headers)
|
|
89
|
+
delay = backoff_delay(self._config.backoff_base, attempt, retry_after)
|
|
90
|
+
if delay > 0:
|
|
91
|
+
await asyncio.sleep(delay)
|
|
92
|
+
attempt += 1
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
body = safe_json(response)
|
|
96
|
+
message = f"{method} {url} failed with status {response.status_code}"
|
|
97
|
+
if response.status_code == 429:
|
|
98
|
+
raise OdosRateLimitError(
|
|
99
|
+
message,
|
|
100
|
+
status_code=429,
|
|
101
|
+
body=body,
|
|
102
|
+
retry_after=parse_retry_after(response.headers),
|
|
103
|
+
)
|
|
104
|
+
raise OdosAPIError(message, status_code=response.status_code, body=body)
|
|
105
|
+
|
|
106
|
+
# -- SOR endpoints -----------------------------------------------------
|
|
107
|
+
|
|
108
|
+
async def quote(self, request: QuoteRequest) -> QuoteResponse:
|
|
109
|
+
"""``POST /sor/quote/v2`` — get a swap path and its ``pathId``."""
|
|
110
|
+
data = await self._request("POST", "/sor/quote/v2", json=request.model_dump(by_alias=True))
|
|
111
|
+
return QuoteResponse.model_validate(data)
|
|
112
|
+
|
|
113
|
+
async def assemble(
|
|
114
|
+
self, *, user_addr: str, path_id: str, simulate: bool = False
|
|
115
|
+
) -> AssembleResponse:
|
|
116
|
+
"""``POST /sor/assemble`` — turn a ``pathId`` into signable calldata."""
|
|
117
|
+
req = AssembleRequest(user_addr=user_addr, path_id=path_id, simulate=simulate)
|
|
118
|
+
data = await self._request("POST", "/sor/assemble", json=req.model_dump(by_alias=True))
|
|
119
|
+
return AssembleResponse.model_validate(data)
|
|
120
|
+
|
|
121
|
+
async def execute(self, *, user_addr: str, path_id: str) -> Any:
|
|
122
|
+
"""``POST /sor/execute`` — assisted execution (returns raw JSON)."""
|
|
123
|
+
return await self._request(
|
|
124
|
+
"POST",
|
|
125
|
+
"/sor/execute",
|
|
126
|
+
json={"userAddr": user_addr, "pathId": path_id},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def swap(
|
|
130
|
+
self, request: QuoteRequest, *, simulate: bool = False
|
|
131
|
+
) -> tuple[QuoteResponse, AssembleResponse]:
|
|
132
|
+
"""Convenience helper: quote then assemble in one awaited call."""
|
|
133
|
+
quote = await self.quote(request)
|
|
134
|
+
if not quote.path_id:
|
|
135
|
+
raise OdosAPIError("Quote response did not include a pathId", body=quote.model_dump())
|
|
136
|
+
assembled = await self.assemble(
|
|
137
|
+
user_addr=request.user_addr, path_id=quote.path_id, simulate=simulate
|
|
138
|
+
)
|
|
139
|
+
return quote, assembled
|
|
140
|
+
|
|
141
|
+
quote_and_assemble = swap
|
|
142
|
+
|
|
143
|
+
# -- info / pricing endpoints -----------------------------------------
|
|
144
|
+
|
|
145
|
+
async def get_chains(self) -> Any:
|
|
146
|
+
"""``GET /info/chains`` — supported chains."""
|
|
147
|
+
return await self._request("GET", "/info/chains")
|
|
148
|
+
|
|
149
|
+
async def get_tokens(self, chain_id: int) -> Any:
|
|
150
|
+
"""``GET /info/tokens/{chainId}`` — token map for a chain."""
|
|
151
|
+
return await self._request("GET", f"/info/tokens/{chain_id}")
|
|
152
|
+
|
|
153
|
+
async def get_router(self, chain_id: int) -> Any:
|
|
154
|
+
"""``GET /info/router/v2/{chainId}`` — router contract address."""
|
|
155
|
+
return await self._request("GET", f"/info/router/v2/{chain_id}")
|
|
156
|
+
|
|
157
|
+
async def get_contract_info(self, chain_id: int) -> Any:
|
|
158
|
+
"""``GET /info/contract-info/v2/{chainId}`` — contract metadata."""
|
|
159
|
+
return await self._request("GET", f"/info/contract-info/v2/{chain_id}")
|
|
160
|
+
|
|
161
|
+
async def get_token_price(self, chain_id: int, token_address: str) -> Any:
|
|
162
|
+
"""``GET /pricing/token/{chainId}/{tokenAddress}`` — token price."""
|
|
163
|
+
return await self._request("GET", f"/pricing/token/{chain_id}/{token_address}")
|
odos_py/client.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Synchronous client for the Odos DEX Aggregator API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._base import (
|
|
12
|
+
DEFAULT_API_KEY_HEADER,
|
|
13
|
+
DEFAULT_BACKOFF_BASE,
|
|
14
|
+
DEFAULT_BASE_URL,
|
|
15
|
+
DEFAULT_MAX_RETRIES,
|
|
16
|
+
DEFAULT_TIMEOUT,
|
|
17
|
+
OdosConfig,
|
|
18
|
+
backoff_delay,
|
|
19
|
+
parse_retry_after,
|
|
20
|
+
safe_json,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import OdosAPIError, OdosRateLimitError
|
|
23
|
+
from .models import AssembleRequest, AssembleResponse, QuoteRequest, QuoteResponse
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OdosClient:
|
|
27
|
+
"""A synchronous client for the Odos API.
|
|
28
|
+
|
|
29
|
+
The free keyless tier is heavily rate limited; pass ``api_key`` to raise
|
|
30
|
+
limits. ``api_key_header`` is configurable because the exact header name is
|
|
31
|
+
not publicly documented.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
38
|
+
api_key: Optional[str] = None,
|
|
39
|
+
api_key_header: str = DEFAULT_API_KEY_HEADER,
|
|
40
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
41
|
+
backoff_base: float = DEFAULT_BACKOFF_BASE,
|
|
42
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
43
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._config = OdosConfig(
|
|
46
|
+
base_url=base_url,
|
|
47
|
+
api_key=api_key,
|
|
48
|
+
api_key_header=api_key_header,
|
|
49
|
+
max_retries=max_retries,
|
|
50
|
+
backoff_base=backoff_base,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
)
|
|
53
|
+
self._client = httpx.Client(
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
headers=self._config.headers(),
|
|
56
|
+
transport=transport,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def base_url(self) -> str:
|
|
61
|
+
return self._config.base_url
|
|
62
|
+
|
|
63
|
+
# -- lifecycle ---------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
self._client.close()
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> OdosClient:
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(
|
|
72
|
+
self,
|
|
73
|
+
exc_type: Optional[type[BaseException]],
|
|
74
|
+
exc: Optional[BaseException],
|
|
75
|
+
tb: Optional[TracebackType],
|
|
76
|
+
) -> None:
|
|
77
|
+
self.close()
|
|
78
|
+
|
|
79
|
+
# -- low-level request with retry/backoff ------------------------------
|
|
80
|
+
|
|
81
|
+
def _request(self, method: str, path: str, *, json: Any = None) -> Any:
|
|
82
|
+
url = self._config.url(path)
|
|
83
|
+
attempt = 0
|
|
84
|
+
while True:
|
|
85
|
+
response = self._client.request(method, url, json=json)
|
|
86
|
+
if response.status_code < 400:
|
|
87
|
+
return safe_json(response)
|
|
88
|
+
|
|
89
|
+
if response.status_code == 429 and attempt < self._config.max_retries:
|
|
90
|
+
retry_after = parse_retry_after(response.headers)
|
|
91
|
+
delay = backoff_delay(self._config.backoff_base, attempt, retry_after)
|
|
92
|
+
if delay > 0:
|
|
93
|
+
time.sleep(delay)
|
|
94
|
+
attempt += 1
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
body = safe_json(response)
|
|
98
|
+
message = f"{method} {url} failed with status {response.status_code}"
|
|
99
|
+
if response.status_code == 429:
|
|
100
|
+
raise OdosRateLimitError(
|
|
101
|
+
message,
|
|
102
|
+
status_code=429,
|
|
103
|
+
body=body,
|
|
104
|
+
retry_after=parse_retry_after(response.headers),
|
|
105
|
+
)
|
|
106
|
+
raise OdosAPIError(message, status_code=response.status_code, body=body)
|
|
107
|
+
|
|
108
|
+
# -- SOR endpoints -----------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def quote(self, request: QuoteRequest) -> QuoteResponse:
|
|
111
|
+
"""``POST /sor/quote/v2`` — get a swap path and its ``pathId``."""
|
|
112
|
+
data = self._request("POST", "/sor/quote/v2", json=request.model_dump(by_alias=True))
|
|
113
|
+
return QuoteResponse.model_validate(data)
|
|
114
|
+
|
|
115
|
+
def assemble(self, *, user_addr: str, path_id: str, simulate: bool = False) -> AssembleResponse:
|
|
116
|
+
"""``POST /sor/assemble`` — turn a ``pathId`` into signable calldata."""
|
|
117
|
+
req = AssembleRequest(user_addr=user_addr, path_id=path_id, simulate=simulate)
|
|
118
|
+
data = self._request("POST", "/sor/assemble", json=req.model_dump(by_alias=True))
|
|
119
|
+
return AssembleResponse.model_validate(data)
|
|
120
|
+
|
|
121
|
+
def execute(self, *, user_addr: str, path_id: str) -> Any:
|
|
122
|
+
"""``POST /sor/execute`` — assisted execution (returns raw JSON)."""
|
|
123
|
+
return self._request(
|
|
124
|
+
"POST",
|
|
125
|
+
"/sor/execute",
|
|
126
|
+
json={"userAddr": user_addr, "pathId": path_id},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def swap(
|
|
130
|
+
self, request: QuoteRequest, *, simulate: bool = False
|
|
131
|
+
) -> tuple[QuoteResponse, AssembleResponse]:
|
|
132
|
+
"""Convenience helper: quote then assemble in one call.
|
|
133
|
+
|
|
134
|
+
Returns the quote and the assembled transaction. Raises
|
|
135
|
+
:class:`OdosAPIError` if the quote did not return a ``pathId``.
|
|
136
|
+
"""
|
|
137
|
+
quote = self.quote(request)
|
|
138
|
+
if not quote.path_id:
|
|
139
|
+
raise OdosAPIError("Quote response did not include a pathId", body=quote.model_dump())
|
|
140
|
+
assembled = self.assemble(
|
|
141
|
+
user_addr=request.user_addr, path_id=quote.path_id, simulate=simulate
|
|
142
|
+
)
|
|
143
|
+
return quote, assembled
|
|
144
|
+
|
|
145
|
+
quote_and_assemble = swap
|
|
146
|
+
|
|
147
|
+
# -- info / pricing endpoints -----------------------------------------
|
|
148
|
+
|
|
149
|
+
def get_chains(self) -> Any:
|
|
150
|
+
"""``GET /info/chains`` — supported chains."""
|
|
151
|
+
return self._request("GET", "/info/chains")
|
|
152
|
+
|
|
153
|
+
def get_tokens(self, chain_id: int) -> Any:
|
|
154
|
+
"""``GET /info/tokens/{chainId}`` — token map for a chain."""
|
|
155
|
+
return self._request("GET", f"/info/tokens/{chain_id}")
|
|
156
|
+
|
|
157
|
+
def get_router(self, chain_id: int) -> Any:
|
|
158
|
+
"""``GET /info/router/v2/{chainId}`` — router contract address."""
|
|
159
|
+
return self._request("GET", f"/info/router/v2/{chain_id}")
|
|
160
|
+
|
|
161
|
+
def get_contract_info(self, chain_id: int) -> Any:
|
|
162
|
+
"""``GET /info/contract-info/v2/{chainId}`` — contract metadata."""
|
|
163
|
+
return self._request("GET", f"/info/contract-info/v2/{chain_id}")
|
|
164
|
+
|
|
165
|
+
def get_token_price(self, chain_id: int, token_address: str) -> Any:
|
|
166
|
+
"""``GET /pricing/token/{chainId}/{tokenAddress}`` — token price."""
|
|
167
|
+
return self._request("GET", f"/pricing/token/{chain_id}/{token_address}")
|
odos_py/exceptions.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Exception hierarchy for the Odos client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OdosError(Exception):
|
|
9
|
+
"""Base class for all errors raised by this library."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OdosAPIError(OdosError):
|
|
13
|
+
"""Raised when the Odos API returns a non-success HTTP status."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
message: str,
|
|
18
|
+
*,
|
|
19
|
+
status_code: Optional[int] = None,
|
|
20
|
+
body: Any = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.body = body
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OdosRateLimitError(OdosAPIError):
|
|
28
|
+
"""Raised on HTTP 429 after retries are exhausted.
|
|
29
|
+
|
|
30
|
+
``retry_after`` is the server-advised wait in seconds, when available.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
message: str,
|
|
36
|
+
*,
|
|
37
|
+
status_code: Optional[int] = None,
|
|
38
|
+
body: Any = None,
|
|
39
|
+
retry_after: Optional[float] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(message, status_code=status_code, body=body)
|
|
42
|
+
self.retry_after = retry_after
|
odos_py/models.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Pydantic models for Odos API requests and responses.
|
|
2
|
+
|
|
3
|
+
All models accept both the API's native ``camelCase`` field names and
|
|
4
|
+
``snake_case`` aliases so the library is ergonomic from Python while staying
|
|
5
|
+
faithful to the wire format. Response models tolerate unknown fields because
|
|
6
|
+
the Odos API returns a large, evolving payload.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
from pydantic.alias_generators import to_camel
|
|
15
|
+
|
|
16
|
+
_REQUEST_CONFIG = ConfigDict(
|
|
17
|
+
alias_generator=to_camel,
|
|
18
|
+
populate_by_name=True,
|
|
19
|
+
serialize_by_alias=True,
|
|
20
|
+
)
|
|
21
|
+
_RESPONSE_CONFIG = ConfigDict(
|
|
22
|
+
alias_generator=to_camel,
|
|
23
|
+
populate_by_name=True,
|
|
24
|
+
extra="allow",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InputToken(BaseModel):
|
|
29
|
+
"""A token being sold, with its amount expressed in wei (base units)."""
|
|
30
|
+
|
|
31
|
+
model_config = _REQUEST_CONFIG
|
|
32
|
+
|
|
33
|
+
token_address: str
|
|
34
|
+
amount: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OutputToken(BaseModel):
|
|
38
|
+
"""A token being bought, with its desired proportion of the output."""
|
|
39
|
+
|
|
40
|
+
model_config = _REQUEST_CONFIG
|
|
41
|
+
|
|
42
|
+
token_address: str
|
|
43
|
+
proportion: float
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class QuoteRequest(BaseModel):
|
|
47
|
+
"""Body for ``POST /sor/quote/v2``."""
|
|
48
|
+
|
|
49
|
+
model_config = _REQUEST_CONFIG
|
|
50
|
+
|
|
51
|
+
chain_id: int
|
|
52
|
+
input_tokens: list[InputToken]
|
|
53
|
+
output_tokens: list[OutputToken]
|
|
54
|
+
user_addr: str
|
|
55
|
+
slippage_limit_percent: float
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class QuoteResponse(BaseModel):
|
|
59
|
+
"""Response from ``POST /sor/quote/v2``.
|
|
60
|
+
|
|
61
|
+
Only the fields commonly needed by callers are typed explicitly; any other
|
|
62
|
+
fields the API returns are preserved (``extra="allow"``).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
model_config = _RESPONSE_CONFIG
|
|
66
|
+
|
|
67
|
+
path_id: Optional[str] = None
|
|
68
|
+
out_amounts: list[str] = Field(default_factory=list)
|
|
69
|
+
in_amounts: list[str] = Field(default_factory=list)
|
|
70
|
+
gas_estimate: Optional[float] = None
|
|
71
|
+
gas_estimate_value: Optional[float] = None
|
|
72
|
+
price_impact: Optional[float] = None
|
|
73
|
+
block_number: Optional[int] = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AssembleRequest(BaseModel):
|
|
77
|
+
"""Body for ``POST /sor/assemble``."""
|
|
78
|
+
|
|
79
|
+
model_config = _REQUEST_CONFIG
|
|
80
|
+
|
|
81
|
+
user_addr: str
|
|
82
|
+
path_id: str
|
|
83
|
+
simulate: bool = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Transaction(BaseModel):
|
|
87
|
+
"""The ready-to-sign transaction returned by ``assemble``."""
|
|
88
|
+
|
|
89
|
+
model_config = _RESPONSE_CONFIG
|
|
90
|
+
|
|
91
|
+
to: Optional[str] = None
|
|
92
|
+
data: Optional[str] = None
|
|
93
|
+
value: Optional[str] = None
|
|
94
|
+
gas: Optional[int] = None
|
|
95
|
+
gas_price: Optional[int] = None
|
|
96
|
+
nonce: Optional[int] = None
|
|
97
|
+
chain_id: Optional[int] = None
|
|
98
|
+
from_: Optional[str] = Field(default=None, alias="from")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AssembleResponse(BaseModel):
|
|
102
|
+
"""Response from ``POST /sor/assemble``."""
|
|
103
|
+
|
|
104
|
+
model_config = _RESPONSE_CONFIG
|
|
105
|
+
|
|
106
|
+
transaction: Optional[Transaction] = None
|
odos_py/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odos-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A modern Python client for the Odos DEX Aggregator API
|
|
5
|
+
Project-URL: Homepage, https://github.com/robertruben98/odos-py
|
|
6
|
+
Project-URL: Documentation, https://docs.odos.xyz/build/api-docs
|
|
7
|
+
Project-URL: Repository, https://github.com/robertruben98/odos-py
|
|
8
|
+
Project-URL: Issues, https://github.com/robertruben98/odos-py/issues
|
|
9
|
+
Author: robertdev
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: aggregator,defi,dex,ethereum,odos,swap
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx>=0.24
|
|
24
|
+
Requires-Dist: pydantic>=2.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
31
|
+
Provides-Extra: exec
|
|
32
|
+
Requires-Dist: web3>=6.0; extra == 'exec'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# odos-py
|
|
36
|
+
|
|
37
|
+
A modern, fully-typed Python client for the [Odos](https://docs.odos.xyz/build/api-docs) DEX Aggregator API.
|
|
38
|
+
|
|
39
|
+
- Sync (`OdosClient`) and async (`AsyncOdosClient`) clients built on `httpx`
|
|
40
|
+
- `pydantic` v2 models for all requests and responses
|
|
41
|
+
- Automatic HTTP 429 handling with exponential backoff
|
|
42
|
+
- Configurable base URL and API-key header
|
|
43
|
+
- `mypy --strict` clean, ships a `py.typed` marker
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install odos-py
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Optional extra for signing/sending the assembled transaction with `web3.py`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install "odos-py[exec]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quickstart
|
|
58
|
+
|
|
59
|
+
Odos works in two steps: `quote` returns a `pathId`, then `assemble` turns that
|
|
60
|
+
`pathId` into ready-to-sign transaction calldata. The `swap()` helper chains
|
|
61
|
+
both:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from odos_py import OdosClient, QuoteRequest, InputToken, OutputToken
|
|
65
|
+
|
|
66
|
+
client = OdosClient(api_key="YOUR_KEY") # api_key optional but recommended
|
|
67
|
+
quote, assembled = client.swap(QuoteRequest(
|
|
68
|
+
chain_id=1,
|
|
69
|
+
input_tokens=[InputToken(token_address="0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", # WETH
|
|
70
|
+
amount="1000000000000000000")], # 1 WETH (wei)
|
|
71
|
+
output_tokens=[OutputToken(token_address="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", # USDC
|
|
72
|
+
proportion=1)],
|
|
73
|
+
user_addr="0x47E2D28169738039755586743E2dfCF3bd643f86",
|
|
74
|
+
slippage_limit_percent=0.3,
|
|
75
|
+
))
|
|
76
|
+
print(quote.path_id, assembled.transaction.to)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Async is identical with `await`:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import asyncio
|
|
83
|
+
from odos_py import AsyncOdosClient
|
|
84
|
+
|
|
85
|
+
async def main(request):
|
|
86
|
+
async with AsyncOdosClient() as client:
|
|
87
|
+
quote = await client.quote(request)
|
|
88
|
+
print(quote.path_id)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Auth & rate limits
|
|
92
|
+
|
|
93
|
+
There is a free, **keyless** tier, but it is heavily rate limited — `POST
|
|
94
|
+
/sor/quote/v2` returns HTTP 429 quickly without a key. Pass an `api_key` to
|
|
95
|
+
raise limits. The exact API-key header name is not publicly documented, so it
|
|
96
|
+
is configurable:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
OdosClient(api_key="YOUR_KEY", api_key_header="x-api-key") # default header name
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The client retries 429 responses with exponential backoff (honouring any
|
|
103
|
+
`Retry-After` header) and raises `OdosRateLimitError` once retries are
|
|
104
|
+
exhausted. Tune with `max_retries` and `backoff_base`.
|
|
105
|
+
|
|
106
|
+
## Endpoints
|
|
107
|
+
|
|
108
|
+
| Method | Endpoint |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `quote(request)` | `POST /sor/quote/v2` |
|
|
111
|
+
| `assemble(user_addr=, path_id=)` | `POST /sor/assemble` |
|
|
112
|
+
| `execute(user_addr=, path_id=)` | `POST /sor/execute` |
|
|
113
|
+
| `swap(request)` / `quote_and_assemble(request)` | quote then assemble |
|
|
114
|
+
| `get_chains()` | `GET /info/chains` |
|
|
115
|
+
| `get_tokens(chain_id)` | `GET /info/tokens/{chainId}` |
|
|
116
|
+
| `get_router(chain_id)` | `GET /info/router/v2/{chainId}` |
|
|
117
|
+
| `get_contract_info(chain_id)` | `GET /info/contract-info/v2/{chainId}` |
|
|
118
|
+
| `get_token_price(chain_id, token_address)` | `GET /pricing/token/{chainId}/{tokenAddress}` |
|
|
119
|
+
|
|
120
|
+
Multi-chain is handled per call via `chain_id` on `QuoteRequest` and the info
|
|
121
|
+
endpoints.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
OdosClient(
|
|
127
|
+
base_url="https://api.odos.xyz", # configurable / proxyable
|
|
128
|
+
api_key=None,
|
|
129
|
+
api_key_header="x-api-key",
|
|
130
|
+
max_retries=3,
|
|
131
|
+
backoff_base=0.5,
|
|
132
|
+
timeout=30.0,
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
pip install -e ".[dev,exec]"
|
|
140
|
+
pytest # unit tests (HTTP mocked, no network)
|
|
141
|
+
pytest -m integration # live smoke test against /info/chains
|
|
142
|
+
ruff check .
|
|
143
|
+
mypy
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
odos_py/__init__.py,sha256=6M2Eu4raTapGlVVbNItTwLEVlBLgJuzlOcBkgZgILDw,689
|
|
2
|
+
odos_py/_base.py,sha256=eVpW_URmVefVAChSegNcn1cUaYngFifpl2SAf78hwbc,3436
|
|
3
|
+
odos_py/async_client.py,sha256=ICyFDH_zm7tMIalMZ60NsrOvYEh66aiuAL45O8NyJtk,6078
|
|
4
|
+
odos_py/client.py,sha256=24MvIazeXvJXF5ERQxrYpV77XME5J-6wiu348cyqsvE,6104
|
|
5
|
+
odos_py/exceptions.py,sha256=MTce7jYyIqTAP14tEQyNDf0NI0J6eO8EUnSKcn_KP60,1043
|
|
6
|
+
odos_py/models.py,sha256=B5LMUbvbnAXMqGF1IVMFRTSg0d9PzdtC7gi_vJrgCQ4,2705
|
|
7
|
+
odos_py/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
odos_py-0.1.0.dist-info/METADATA,sha256=IAf2aclXr4-esBRI8vx5JSsag1xe1CloF1YxnEB9YJ0,4735
|
|
9
|
+
odos_py-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
odos_py-0.1.0.dist-info/licenses/LICENSE,sha256=-AcjerVulaZXw9Ub2qG825vAXbFN8KNwHs9VEcf2XDo,1069
|
|
11
|
+
odos_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Ruben
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|