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 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
+