tristero 0.1.4__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.
- tristero/__init__.py +12 -0
- tristero/api.py +158 -0
- tristero/client.py +147 -0
- tristero/config.py +19 -0
- tristero/files/erc20_abi.json +671 -0
- tristero/files/permit2_abi.json +411 -0
- tristero/permit2.py +384 -0
- tristero/py.typed +0 -0
- tristero-0.1.4.dist-info/METADATA +157 -0
- tristero-0.1.4.dist-info/RECORD +11 -0
- tristero-0.1.4.dist-info/WHEEL +4 -0
tristero/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from tristero.client import OrderFailedException, StuckException, SwapException, TokenSpec, execute_swap, start_swap
|
|
2
|
+
from tristero.config import set_config
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"TokenSpec",
|
|
6
|
+
"StuckException",
|
|
7
|
+
"OrderFailedException",
|
|
8
|
+
"SwapException",
|
|
9
|
+
"execute_swap",
|
|
10
|
+
"start_swap",
|
|
11
|
+
"set_config",
|
|
12
|
+
]
|
tristero/api.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any
|
|
3
|
+
import httpx
|
|
4
|
+
from websockets.asyncio.client import connect
|
|
5
|
+
from tristero.config import get_config
|
|
6
|
+
|
|
7
|
+
class ChainID(str, Enum):
|
|
8
|
+
arbitrum = "42161"
|
|
9
|
+
avalanche = "43114"
|
|
10
|
+
base = "8453"
|
|
11
|
+
blast = "81457"
|
|
12
|
+
bsc = "56"
|
|
13
|
+
celo = "42220"
|
|
14
|
+
ethereum = "1"
|
|
15
|
+
mantle = "5000"
|
|
16
|
+
mode = "34443"
|
|
17
|
+
opbnb = "204"
|
|
18
|
+
optimism = "10"
|
|
19
|
+
polygon = "137"
|
|
20
|
+
scroll = "534352"
|
|
21
|
+
solana = "1151111081099710"
|
|
22
|
+
tron = "728126428"
|
|
23
|
+
unichain = "130"
|
|
24
|
+
sei = "1329"
|
|
25
|
+
sonic = "146"
|
|
26
|
+
linea = "59144"
|
|
27
|
+
worldchain = "480"
|
|
28
|
+
codex = "81224"
|
|
29
|
+
plume = "98866"
|
|
30
|
+
hyperevm = "999"
|
|
31
|
+
|
|
32
|
+
_WRAPPED_GAS_ADDRESSES = {
|
|
33
|
+
ChainID.ethereum: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
|
34
|
+
ChainID.unichain: "0x4200000000000000000000000000000000000006",
|
|
35
|
+
ChainID.optimism: "0x4200000000000000000000000000000000000006",
|
|
36
|
+
ChainID.arbitrum: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
|
|
37
|
+
ChainID.avalanche: "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7",
|
|
38
|
+
ChainID.polygon: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270",
|
|
39
|
+
ChainID.base: "0x4200000000000000000000000000000000000006",
|
|
40
|
+
ChainID.bsc: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c",
|
|
41
|
+
ChainID.blast: "0x4300000000000000000000000000000000000004",
|
|
42
|
+
ChainID.mode: "0x4200000000000000000000000000000000000006",
|
|
43
|
+
ChainID.solana: "So11111111111111111111111111111111111111112",
|
|
44
|
+
ChainID.sonic: "0x039e2fB66102314Ce7b64Ce5Ce3E5183bc94aD38",
|
|
45
|
+
ChainID.linea: "0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f",
|
|
46
|
+
ChainID.sei: "0xE30feDd158A2e3b13e9badaeABaFc5516e95e8C7",
|
|
47
|
+
ChainID.worldchain: "0x4200000000000000000000000000000000000006",
|
|
48
|
+
ChainID.plume: "0xEa237441c92CAe6FC17Caaf9a7acB3f953be4bd1",
|
|
49
|
+
ChainID.hyperevm: "0x5555555555555555555555555555555555555555",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_PERMIT2_CONTRACT_ADDRESSES = {
|
|
53
|
+
ChainID.ethereum: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
54
|
+
ChainID.avalanche: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
55
|
+
ChainID.arbitrum: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
56
|
+
ChainID.base: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
57
|
+
ChainID.bsc: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
58
|
+
ChainID.optimism: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
59
|
+
ChainID.polygon: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
60
|
+
ChainID.unichain: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
61
|
+
ChainID.linea: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
62
|
+
ChainID.worldchain: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
63
|
+
ChainID.sonic: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
64
|
+
ChainID.sei: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
65
|
+
ChainID.blast: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
66
|
+
ChainID.mode: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
67
|
+
ChainID.scroll: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
68
|
+
ChainID.plume: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
69
|
+
ChainID.hyperevm: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class APIException(Exception):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
class QuoteException(Exception):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def handle_resp(resp: httpx.Response):
|
|
79
|
+
try:
|
|
80
|
+
resp.raise_for_status()
|
|
81
|
+
data = resp.json()
|
|
82
|
+
return data
|
|
83
|
+
except Exception as e:
|
|
84
|
+
try:
|
|
85
|
+
data = resp.json()
|
|
86
|
+
except Exception as json_e:
|
|
87
|
+
if resp.text:
|
|
88
|
+
raise APIException(resp.text)
|
|
89
|
+
else:
|
|
90
|
+
raise e
|
|
91
|
+
raise APIException(data)
|
|
92
|
+
|
|
93
|
+
async def t_get(client: httpx.AsyncClient, url: str):
|
|
94
|
+
resp = await client.get(url, headers=get_config().headers, timeout=20)
|
|
95
|
+
return handle_resp(resp)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def t_post(client: httpx.AsyncClient, url: str, body: dict[str, Any] | None):
|
|
99
|
+
resp = await client.post(url, headers=get_config().headers, json=body, timeout=20)
|
|
100
|
+
return handle_resp(resp)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def native_address(chain_id: ChainID) -> str:
|
|
104
|
+
return "0x0000000000000000000000000000000000000000"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def or_native(chain_id: ChainID, address: str):
|
|
108
|
+
return native_address(chain_id) if address == "native" else address
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def get_quote(
|
|
112
|
+
from_wallet: str,
|
|
113
|
+
to_wallet: str,
|
|
114
|
+
from_chain_id: ChainID,
|
|
115
|
+
from_address: str,
|
|
116
|
+
to_chain_id: ChainID,
|
|
117
|
+
to_address: str,
|
|
118
|
+
amount: int,
|
|
119
|
+
):
|
|
120
|
+
from_chain_id = from_chain_id
|
|
121
|
+
to_chain_id = to_chain_id
|
|
122
|
+
|
|
123
|
+
from_token_address = or_native(from_chain_id, from_address)
|
|
124
|
+
to_token_address = or_native(to_chain_id, to_address)
|
|
125
|
+
|
|
126
|
+
async with httpx.AsyncClient() as c:
|
|
127
|
+
data = {
|
|
128
|
+
"src_chain_id": from_chain_id.value,
|
|
129
|
+
"src_token_address": from_token_address,
|
|
130
|
+
"src_token_quantity": str(int(amount)),
|
|
131
|
+
"src_wallet_address": from_wallet,
|
|
132
|
+
"dst_chain_id": to_chain_id.value,
|
|
133
|
+
"dst_token_address": to_token_address,
|
|
134
|
+
"dst_wallet_address": to_wallet,
|
|
135
|
+
}
|
|
136
|
+
resp = await c.post(
|
|
137
|
+
get_config().quoter_url,
|
|
138
|
+
json=data,
|
|
139
|
+
)
|
|
140
|
+
# print(data, resp)
|
|
141
|
+
try:
|
|
142
|
+
return handle_resp(resp)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
raise QuoteException(e, data) from e
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def fill_order(signature: str, domain: dict[str, Any], message: dict[str, Any]):
|
|
148
|
+
async with httpx.AsyncClient() as c:
|
|
149
|
+
data = {"signature": signature, "domain": domain, "message": message}
|
|
150
|
+
resp = await c.post(
|
|
151
|
+
get_config().filler_url,
|
|
152
|
+
json=data,
|
|
153
|
+
)
|
|
154
|
+
return handle_resp(resp)
|
|
155
|
+
|
|
156
|
+
async def poll_updates(order_id: str):
|
|
157
|
+
ws = await connect(f"{get_config().ws_url}/{order_id}")
|
|
158
|
+
return ws
|
tristero/client.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from eth_account.signers.local import LocalAccount
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from tristero.api import ChainID, fill_order, poll_updates
|
|
10
|
+
from .permit2 import create_order
|
|
11
|
+
from web3 import AsyncBaseProvider, AsyncWeb3
|
|
12
|
+
import logging
|
|
13
|
+
from web3 import AsyncWeb3
|
|
14
|
+
from tenacity import (
|
|
15
|
+
retry,
|
|
16
|
+
stop_after_attempt,
|
|
17
|
+
wait_exponential,
|
|
18
|
+
retry_if_exception_type,
|
|
19
|
+
)
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
P = TypeVar("P", bound=AsyncBaseProvider)
|
|
23
|
+
|
|
24
|
+
class WebSocketClosedError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
class SwapException(Exception):
|
|
28
|
+
"""Base exception for all swap-related errors."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StuckException(SwapException):
|
|
33
|
+
"""Raised when swap execution times out"""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OrderFailedException(SwapException):
|
|
38
|
+
"""Order execution failed on-chain."""
|
|
39
|
+
def __init__(self, message: str, order_id: str, details: dict[str, Any]):
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.order_id = order_id
|
|
42
|
+
self.details = details
|
|
43
|
+
|
|
44
|
+
class TokenSpec(BaseModel, frozen=True):
|
|
45
|
+
chain_id: ChainID
|
|
46
|
+
token_address: str
|
|
47
|
+
|
|
48
|
+
async def wait_for_completion(order_id: str):
|
|
49
|
+
ws = await poll_updates(order_id)
|
|
50
|
+
try:
|
|
51
|
+
async for msg in ws:
|
|
52
|
+
msg = json.loads(msg)
|
|
53
|
+
logger.info(
|
|
54
|
+
{
|
|
55
|
+
"message": f"failed={msg['failed']} completed={msg['completed']}",
|
|
56
|
+
"id": "order_update",
|
|
57
|
+
"payload": msg,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
if msg["failed"]:
|
|
61
|
+
await ws.close()
|
|
62
|
+
raise Exception(f"Swap failed: {ws.close_reason} {msg}")
|
|
63
|
+
elif msg["completed"]:
|
|
64
|
+
await ws.close()
|
|
65
|
+
return msg
|
|
66
|
+
|
|
67
|
+
# If we exit the loop without completed/failed, raise to retry
|
|
68
|
+
raise WebSocketClosedError("WebSocket closed without completion status")
|
|
69
|
+
except Exception:
|
|
70
|
+
# Close cleanly if still open
|
|
71
|
+
if not ws.close_code:
|
|
72
|
+
await ws.close()
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
@retry(
|
|
76
|
+
stop=stop_after_attempt(3),
|
|
77
|
+
wait=wait_exponential(multiplier=1, min=4, max=10),
|
|
78
|
+
retry=retry_if_exception_type(WebSocketClosedError)
|
|
79
|
+
)
|
|
80
|
+
async def wait_for_completion_with_retry(order_id: str):
|
|
81
|
+
return await wait_for_completion(order_id)
|
|
82
|
+
|
|
83
|
+
async def start_swap(w3: AsyncWeb3[P], account: LocalAccount, from_t: TokenSpec, to_t: TokenSpec, raw_amount: int) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Execute a token swap operation.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
w3: Web3 provider instance
|
|
89
|
+
account: Account to execute swap from
|
|
90
|
+
from_t: Source token specification
|
|
91
|
+
to_t: Target token specification
|
|
92
|
+
raw_amount: Amount in smallest unit (e.g., wei)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Order ID for tracking the swap
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
Exception: If order creation or submission fails
|
|
99
|
+
"""
|
|
100
|
+
data, sig = await create_order(
|
|
101
|
+
w3,
|
|
102
|
+
account,
|
|
103
|
+
from_t.chain_id,
|
|
104
|
+
from_t.token_address,
|
|
105
|
+
to_t.chain_id,
|
|
106
|
+
to_t.token_address,
|
|
107
|
+
raw_amount,
|
|
108
|
+
)
|
|
109
|
+
response = await fill_order(
|
|
110
|
+
str(sig.signature.to_0x_hex()),
|
|
111
|
+
data.domain.model_dump(by_alias=True, mode="json"),
|
|
112
|
+
data.message.model_dump(by_alias=True, mode="json"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return response['id']
|
|
116
|
+
|
|
117
|
+
async def execute_swap(
|
|
118
|
+
w3: AsyncWeb3[P],
|
|
119
|
+
account: LocalAccount,
|
|
120
|
+
src_t: TokenSpec,
|
|
121
|
+
dst_t: TokenSpec,
|
|
122
|
+
raw_amount: int,
|
|
123
|
+
retry: bool = True,
|
|
124
|
+
timeout: float | None = None
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
"""Execute and wait for swap completion."""
|
|
127
|
+
order_id = await start_swap(
|
|
128
|
+
w3,
|
|
129
|
+
account,
|
|
130
|
+
src_t,
|
|
131
|
+
dst_t,
|
|
132
|
+
raw_amount
|
|
133
|
+
)
|
|
134
|
+
logger.info(f"Swap order placed: {order_id}")
|
|
135
|
+
|
|
136
|
+
waiter = wait_for_completion_with_retry if retry else wait_for_completion
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
if timeout is None:
|
|
140
|
+
return await waiter(order_id)
|
|
141
|
+
|
|
142
|
+
return await asyncio.wait_for(
|
|
143
|
+
waiter(order_id),
|
|
144
|
+
timeout=timeout
|
|
145
|
+
)
|
|
146
|
+
except asyncio.TimeoutError as exc:
|
|
147
|
+
raise StuckException(f"Swap {order_id} timed out after {timeout}s") from exc
|
tristero/config.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
|
|
3
|
+
class Config:
|
|
4
|
+
filler_url = "https://api.tristero.com/v2/orders"
|
|
5
|
+
quoter_url = "https://api.tristero.com/v2/quotes"
|
|
6
|
+
ws_url = "wss://api.tristero.com/v2/orders"
|
|
7
|
+
|
|
8
|
+
headers = {
|
|
9
|
+
"User-Agent": "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
config_var = ContextVar("config", default=Config())
|
|
13
|
+
|
|
14
|
+
def get_config():
|
|
15
|
+
return config_var.get()
|
|
16
|
+
|
|
17
|
+
def set_config(new_config: Config):
|
|
18
|
+
return config_var.set(new_config)
|
|
19
|
+
|