tristero 0.1.6__py3-none-any.whl → 0.2.1__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 CHANGED
@@ -1,12 +1,18 @@
1
- from tristero.client import OrderFailedException, StuckException, SwapException, TokenSpec, execute_swap, start_swap
1
+ from tristero.client import OrderFailedException, StuckException, SwapException, TokenSpec, execute_permit2_swap, start_permit2_swap, start_feather_swap, wait_for_feather_completion, wait_for_completion, wait_for_completion_with_retry
2
2
  from tristero.config import set_config
3
+ from tristero.data import ChainID
3
4
 
4
5
  __all__ = [
6
+ "ChainID",
5
7
  "TokenSpec",
6
8
  "StuckException",
7
9
  "OrderFailedException",
8
10
  "SwapException",
9
- "execute_swap",
10
- "start_swap",
11
+ "start_permit2_swap",
12
+ "start_feather_swap",
13
+ "execute_permit2_swap",
14
+ "wait_for_feather_completion",
15
+ "wait_for_completion",
16
+ "wait_for_completion_with_retry",
11
17
  "set_config",
12
18
  ]
tristero/api.py CHANGED
@@ -1,73 +1,8 @@
1
- from enum import Enum
2
1
  from typing import Any, Optional
3
2
  import httpx
4
3
  from websockets.asyncio.client import connect
5
4
  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
- }
5
+ from tristero.data import get_gas_addr
71
6
 
72
7
  class APIException(Exception):
73
8
  pass
@@ -99,21 +34,17 @@ async def t_post(client: httpx.AsyncClient, url: str, body: Optional[dict[str, A
99
34
  resp = await client.post(url, headers=get_config().headers, json=body, timeout=20)
100
35
  return handle_resp(resp)
101
36
 
37
+ def or_native(chain_id: str, address: str):
38
+ return get_gas_addr(chain_id) if address == "native" else address
102
39
 
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
-
40
+ c = httpx.AsyncClient()
110
41
 
111
42
  async def get_quote(
112
43
  from_wallet: str,
113
44
  to_wallet: str,
114
- from_chain_id: ChainID,
45
+ from_chain_id: str,
115
46
  from_address: str,
116
- to_chain_id: ChainID,
47
+ to_chain_id: str,
117
48
  to_address: str,
118
49
  amount: int,
119
50
  ):
@@ -123,36 +54,51 @@ async def get_quote(
123
54
  from_token_address = or_native(from_chain_id, from_address)
124
55
  to_token_address = or_native(to_chain_id, to_address)
125
56
 
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
-
57
+ data = {
58
+ "src_chain_id": from_chain_id,
59
+ "src_token_address": from_token_address,
60
+ "src_token_quantity": str(int(amount)),
61
+ "src_wallet_address": from_wallet,
62
+ "dst_chain_id": to_chain_id,
63
+ "dst_token_address": to_token_address,
64
+ "dst_wallet_address": to_wallet,
65
+ }
66
+ resp = await c.post(
67
+ get_config().quoter_url,
68
+ json=data,
69
+ )
70
+ # print(data, resp)
71
+ try:
72
+ return handle_resp(resp)
73
+ except Exception as e:
74
+ raise QuoteException(e, data) from e
146
75
 
147
76
  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)
77
+ data = {"signature": signature, "domain": domain, "message": message}
78
+ resp = await c.post(
79
+ get_config().filler_url,
80
+ json=data,
81
+ )
82
+ return handle_resp(resp)
83
+
84
+ async def fill_order_feather(src_chain: str, dst_chain: str, dst_address: str, amount: int, client_id: str = ''):
85
+ data = {
86
+ "client_id": client_id,
87
+ "src_chain": src_chain,
88
+ "dst_chain": dst_chain,
89
+ "dst_address": dst_address,
90
+ "amount": amount
91
+ }
92
+ resp = await c.post(
93
+ get_config().filler_url,
94
+ json=data,
95
+ )
96
+ return handle_resp(resp)
155
97
 
156
98
  async def poll_updates(order_id: str):
157
99
  ws = await connect(f"{get_config().ws_url}/{order_id}")
158
100
  return ws
101
+
102
+ async def poll_updates_feather(order_id: str):
103
+ ws = await connect(f"{get_config().ws_url}/feather/{order_id}")
104
+ return ws
tristero/client.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ from dataclasses import dataclass
2
3
  import json
3
4
  import logging
4
5
  from typing import Any, Optional, TypeVar
@@ -6,10 +7,10 @@ from typing import Any, Optional, TypeVar
6
7
  from eth_account.signers.local import LocalAccount
7
8
  from pydantic import BaseModel
8
9
 
9
- from tristero.api import ChainID, fill_order, poll_updates
10
- from .permit2 import create_order
10
+ from tristero.api import fill_order, fill_order_feather, get_quote, poll_updates, poll_updates_feather
11
+ from .permit2 import Permit2Order, create_permit2_order
12
+ from .data import ChainID
11
13
  from web3 import AsyncBaseProvider, AsyncWeb3
12
- import logging
13
14
  from web3 import AsyncWeb3
14
15
  from tenacity import (
15
16
  retry,
@@ -45,7 +46,36 @@ class TokenSpec(BaseModel, frozen=True):
45
46
  chain_id: ChainID
46
47
  token_address: str
47
48
 
48
- async def wait_for_completion(order_id: str):
49
+ async def wait_for_feather_completion(order_id: str):
50
+ ws = await poll_updates_feather(order_id)
51
+ try:
52
+ async for msg in ws:
53
+ if not msg:
54
+ continue
55
+ msg = json.loads(msg)
56
+ status = msg['status']
57
+ logger.info(
58
+ {
59
+ "message": f"status={status}",
60
+ "id": "order_update",
61
+ "payload": msg,
62
+ }
63
+ )
64
+ if status in ['Expired']:
65
+ await ws.close()
66
+ raise Exception(f"Swap failed: {ws.close_reason} {msg}")
67
+ elif status in ['Finalized']:
68
+ await ws.close()
69
+ return msg
70
+ # If we exit the loop without completed/failed, raise to retry
71
+ raise WebSocketClosedError("WebSocket closed without completion status")
72
+ except Exception as e:
73
+ # Close cleanly if still open
74
+ if not ws.close_code:
75
+ await ws.close()
76
+ raise
77
+
78
+ async def wait_for_permit2_completion(order_id: str):
49
79
  ws = await poll_updates(order_id)
50
80
  try:
51
81
  async for msg in ws:
@@ -72,49 +102,69 @@ async def wait_for_completion(order_id: str):
72
102
  await ws.close()
73
103
  raise
74
104
 
105
+ async def wait_for_completion(order_id: str, feather: bool):
106
+ if feather:
107
+ return await wait_for_feather_completion(order_id)
108
+ else:
109
+ return await wait_for_permit2_completion(order_id)
110
+
75
111
  @retry(
76
112
  stop=stop_after_attempt(3),
77
113
  wait=wait_exponential(multiplier=1, min=4, max=10),
78
114
  retry=retry_if_exception_type(WebSocketClosedError)
79
115
  )
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(
116
+ async def wait_for_completion_with_retry(order_id: str, feather: bool = False):
117
+ return await wait_for_completion(order_id, feather)
118
+
119
+ @dataclass
120
+ class FeatherSwapResult:
121
+ deposit_address: str
122
+ data: Any
123
+
124
+ class FeatherException(Exception):
125
+ pass
126
+
127
+ async def start_feather_swap(
128
+ src_t: TokenSpec,
129
+ dst_t: TokenSpec,
130
+ dst_addr: str,
131
+ raw_amount: int,
132
+ client_id: str = ''
133
+ ):
134
+ resp = await fill_order_feather(client_id, str(src_t.chain_id.value), str(dst_t.chain_id), dst_addr, raw_amount)
135
+ if resp['detail']:
136
+ raise FeatherException(resp)
137
+ else:
138
+ return FeatherSwapResult(
139
+ deposit_address = resp['deposit_address'],
140
+ data = resp
141
+ )
142
+
143
+ async def start_permit2_swap(
144
+ w3: AsyncWeb3[P],
145
+ account: LocalAccount,
146
+ src_t: TokenSpec,
147
+ dst_t: TokenSpec,
148
+ raw_amount: int,
149
+ ):
150
+ order = await create_permit2_order(
101
151
  w3,
102
152
  account,
103
- from_t.chain_id,
104
- from_t.token_address,
105
- to_t.chain_id,
106
- to_t.token_address,
153
+ str(src_t.chain_id.value),
154
+ src_t.token_address,
155
+ str(dst_t.chain_id.value),
156
+ dst_t.token_address,
107
157
  raw_amount,
108
158
  )
109
159
  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"),
160
+ str(order.sig.signature.to_0x_hex()),
161
+ order.msg.domain.model_dump(by_alias=True, mode="json"),
162
+ order.msg.message.model_dump(by_alias=True, mode="json"),
113
163
  )
164
+ order_id = response['id']
165
+ return order_id
114
166
 
115
- return response['id']
116
-
117
- async def execute_swap(
167
+ async def execute_permit2_swap(
118
168
  w3: AsyncWeb3[P],
119
169
  account: LocalAccount,
120
170
  src_t: TokenSpec,
@@ -124,20 +174,14 @@ async def execute_swap(
124
174
  timeout: Optional[float] = None
125
175
  ) -> dict[str, Any]:
126
176
  """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
- )
177
+ order_id = await start_permit2_swap(w3, account, src_t, dst_t, raw_amount)
134
178
  logger.info(f"Swap order placed: {order_id}")
135
179
 
136
180
  waiter = wait_for_completion_with_retry if retry else wait_for_completion
137
181
 
138
182
  try:
139
183
  if timeout is None:
140
- return await waiter(order_id)
184
+ return await waiter(order_id, False)
141
185
 
142
186
  return await asyncio.wait_for(
143
187
  waiter(order_id),
@@ -145,3 +189,4 @@ async def execute_swap(
145
189
  )
146
190
  except asyncio.TimeoutError as exc:
147
191
  raise StuckException(f"Swap {order_id} timed out after {timeout}s") from exc
192
+
tristero/data.py ADDED
@@ -0,0 +1,35 @@
1
+ from importlib import resources as impresources
2
+ import json
3
+ from typing import TYPE_CHECKING
4
+ import enum
5
+
6
+ CHAINS_FILE = impresources.files("tristero.files") / "chains.json"
7
+ CHAINS = json.loads(CHAINS_FILE.read_text())
8
+
9
+ def get_address(chain: str, id: str) -> str | None:
10
+ return CHAINS[chain].get('addresses').get(id)
11
+
12
+ def get_permit2_addr(chain: str):
13
+ return get_address(chain, 'permit2')
14
+
15
+ def get_wrapped_gas_addr(chain: str):
16
+ return get_address(chain, 'wrappedGasToken')
17
+
18
+ def get_gas_addr(chain: str):
19
+ return get_address(chain, 'gasToken')
20
+
21
+ def chain(id: str) -> str | None:
22
+ return CHAINS[str(id)].get('chainId')
23
+
24
+ if TYPE_CHECKING:
25
+ from enum import IntEnum
26
+ class ChainID(IntEnum):
27
+ _value_: int
28
+ else:
29
+ ChainID = enum.Enum(
30
+ 'ChainID',
31
+ {
32
+ name: data['chainId']
33
+ for name, data in CHAINS.items()
34
+ }
35
+ )