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/permit2.py ADDED
@@ -0,0 +1,384 @@
1
+ import logging
2
+ from typing import Any, List, Optional, TypeVar, cast
3
+ from eth_account import Account
4
+ from eth_account.datastructures import SignedMessage, SignedTransaction
5
+ from eth_account.signers.base import BaseAccount
6
+ from eth_account.signers.local import LocalAccount
7
+ from eth_account.types import TransactionDictType
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
9
+ from pydantic.alias_generators import to_camel
10
+ from web3 import AsyncBaseProvider, AsyncWeb3
11
+ from eth_typing import Address, ChecksumAddress
12
+ import time
13
+ import math
14
+ import random
15
+ import web3
16
+ from web3.contract import AsyncContract
17
+ from functools import cache, lru_cache
18
+ import json
19
+ from pathlib import Path
20
+ from importlib import resources as impresources
21
+
22
+ from web3 import Web3
23
+ from web3.eth import AsyncEth
24
+
25
+ from .api import (
26
+ _WRAPPED_GAS_ADDRESSES,
27
+ get_quote,
28
+ _PERMIT2_CONTRACT_ADDRESSES,
29
+ ChainID,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ P = TypeVar("P", bound=AsyncBaseProvider)
35
+
36
+ PERMIT2_ABI_FILE = impresources.files("tristero.files") / "permit2_abi.json"
37
+ ERC20_ABI_FILE = impresources.files("tristero.files") / "erc20_abi.json"
38
+ PERMIT2_ABI = json.loads(PERMIT2_ABI_FILE.read_text())
39
+ ERC20_ABI = json.loads(ERC20_ABI_FILE.read_text())
40
+
41
+
42
+ @lru_cache(maxsize=None)
43
+ def get_permit2(eth: AsyncEth, permit2_address: str):
44
+ return eth.contract(
45
+ address=Web3.to_checksum_address(permit2_address), abi=PERMIT2_ABI
46
+ )
47
+
48
+ @cache
49
+ def get_erc20_contract(w3: AsyncWeb3[P], token_address: str) -> AsyncContract:
50
+ """Get ERC20 contract instance."""
51
+ return w3.eth.contract(
52
+ address=Web3.to_checksum_address(token_address), abi=ERC20_ABI
53
+ )
54
+
55
+
56
+ class BaseSchema(BaseModel, frozen=True):
57
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
58
+
59
+
60
+ class TokenData(BaseSchema, frozen=True):
61
+ address: str
62
+ value: int
63
+
64
+
65
+ class ChainData(BaseSchema, frozen=True):
66
+ chain_id: int
67
+ token: TokenData
68
+
69
+
70
+ class OrderParameters(BaseSchema, frozen=True):
71
+ src_asset: str
72
+ dst_asset: str
73
+ src_quantity: str
74
+ dst_quantity: str
75
+ min_quantity: str
76
+ dark_salt: str
77
+
78
+
79
+ class OrderData(BaseSchema, frozen=True):
80
+ parameters: OrderParameters
81
+ deadline: int
82
+ router_address: str
83
+ filler_wallet_address: str
84
+ order_type: str
85
+ custom_data: List[bytes] = Field(default_factory=list)
86
+
87
+
88
+ class Quote(BaseSchema, frozen=True):
89
+ order_data: OrderData
90
+
91
+
92
+ class SignedOrder(BaseSchema, frozen=True):
93
+ sender: str
94
+ parameters: OrderParameters
95
+ deadline: str
96
+ target: str
97
+ filler: str
98
+ order_type: str
99
+ custom_data: List[bytes]
100
+
101
+
102
+ class TokenPermissions(BaseSchema, frozen=True):
103
+ token: str
104
+ amount: str
105
+
106
+
107
+ class EIP712Domain(BaseSchema, frozen=True):
108
+ name: str
109
+ chain_id: int
110
+ verifying_contract: str
111
+
112
+
113
+ class PermitMessage(BaseSchema, frozen=True):
114
+ permitted: TokenPermissions
115
+ spender: str
116
+ nonce: str
117
+ deadline: str
118
+ witness: SignedOrder
119
+
120
+
121
+ class SignatureData(BaseSchema, frozen=True):
122
+ domain: EIP712Domain
123
+ types: dict[str, Any]
124
+ primary_type: str
125
+ message: PermitMessage
126
+
127
+
128
+ def first_zero_bit(n: int) -> int | None:
129
+ if n > 0 and (n & (n + 1)) == 0:
130
+ return None
131
+ return (n ^ (n + 1)).bit_length() - 1
132
+
133
+
134
+ async def get_permit2_unordered_nonce(c: AsyncContract, wallet_address: str):
135
+ startWord = random.randint(2**126, 2**189)
136
+ endWord = startWord + 10
137
+ wordPos = await anext(
138
+ (
139
+ ((w << 8) | bit)
140
+ for w in range(startWord, endWord)
141
+ if (
142
+ bit := first_zero_bit(
143
+ await c.functions.nonceBitmap(wallet_address, w).call()
144
+ )
145
+ )
146
+ is not None
147
+ ),
148
+ None,
149
+ )
150
+ if wordPos is None:
151
+ raise Exception(f"No free unordered nonces in words {startWord}-{endWord}")
152
+ return wordPos
153
+
154
+
155
+ async def prepare_data_for_signature(
156
+ eth: AsyncEth,
157
+ sell_data: ChainData,
158
+ buy_data: ChainData,
159
+ wallet_address: str,
160
+ quote: Quote,
161
+ destination_address: str | None = None,
162
+ ) -> SignatureData:
163
+ """
164
+ Prepare EIP-712 signature data for Permit2 witness transfer.
165
+
166
+ Args:
167
+ sell_data: Source chain and token data
168
+ buy_data: Destination chain and token data
169
+ wallet_address: User's wallet address
170
+ quote: Quote data with order parameters and deadline
171
+ destination_address: Optional destination address
172
+
173
+ Returns:
174
+ SignatureData with domain, types, primaryType, and message for signing
175
+ """
176
+ # Validate required fields
177
+ if not quote.order_data.parameters.min_quantity:
178
+ raise ValueError("Min quantity is required in the order_data.parameters")
179
+
180
+ if not quote.order_data.router_address:
181
+ raise ValueError("Router address is required in the order_data")
182
+
183
+ if not quote.order_data.deadline:
184
+ raise ValueError("Deadline is required in the order_data")
185
+
186
+ from_chain = ChainID(str(sell_data.chain_id))
187
+
188
+ deadline = quote.order_data.deadline
189
+
190
+ # Handle native token address conversion
191
+ token_address = (
192
+ _WRAPPED_GAS_ADDRESSES[from_chain]
193
+ if sell_data.token.address == "native"
194
+ else sell_data.token.address
195
+ )
196
+
197
+ # Build witness object
198
+ witness = SignedOrder(
199
+ sender=wallet_address,
200
+ parameters=OrderParameters(
201
+ src_asset=token_address,
202
+ dst_asset=buy_data.token.address,
203
+ src_quantity=str(sell_data.token.value),
204
+ dst_quantity=str(buy_data.token.value),
205
+ min_quantity=quote.order_data.parameters.min_quantity,
206
+ dark_salt=quote.order_data.parameters.dark_salt,
207
+ ),
208
+ deadline=str(deadline),
209
+ target=destination_address or wallet_address,
210
+ filler=quote.order_data.filler_wallet_address,
211
+ order_type=quote.order_data.order_type,
212
+ custom_data=quote.order_data.custom_data or [],
213
+ )
214
+
215
+ # Get Permit2 address
216
+ permit2_address = _PERMIT2_CONTRACT_ADDRESSES.get(from_chain)
217
+ if not permit2_address:
218
+ raise ValueError("Permit2 not deployed on this chain.")
219
+
220
+ spender = quote.order_data.router_address
221
+ nonce = await get_permit2_unordered_nonce(
222
+ get_permit2(eth, permit2_address), wallet_address
223
+ )
224
+
225
+ # EIP-712 domain
226
+ domain = EIP712Domain(
227
+ name="Permit2",
228
+ chain_id=sell_data.chain_id,
229
+ verifying_contract=permit2_address,
230
+ )
231
+
232
+ # EIP-712 types
233
+ types = {
234
+ "TokenPermissions": [
235
+ {"name": "token", "type": "address"},
236
+ {"name": "amount", "type": "uint256"},
237
+ ],
238
+ "OrderParameters": [
239
+ {"name": "srcAsset", "type": "address"},
240
+ {"name": "dstAsset", "type": "address"},
241
+ {"name": "srcQuantity", "type": "uint256"},
242
+ {"name": "dstQuantity", "type": "uint256"},
243
+ {"name": "minQuantity", "type": "uint256"},
244
+ {"name": "darkSalt", "type": "uint128"},
245
+ ],
246
+ "SignedOrder": [
247
+ {"name": "sender", "type": "address"},
248
+ {"name": "parameters", "type": "OrderParameters"},
249
+ {"name": "deadline", "type": "uint256"},
250
+ {"name": "target", "type": "address"},
251
+ {"name": "filler", "type": "address"},
252
+ {"name": "orderType", "type": "string"},
253
+ {"name": "customData", "type": "bytes[]"},
254
+ ],
255
+ "PermitWitnessTransferFrom": [
256
+ {"name": "permitted", "type": "TokenPermissions"},
257
+ {"name": "spender", "type": "address"},
258
+ {"name": "nonce", "type": "uint256"},
259
+ {"name": "deadline", "type": "uint256"},
260
+ {"name": "witness", "type": "SignedOrder"},
261
+ ],
262
+ }
263
+
264
+ # Build message
265
+ message = PermitMessage(
266
+ permitted=TokenPermissions(
267
+ token=token_address,
268
+ amount=str(sell_data.token.value),
269
+ ),
270
+ spender=spender,
271
+ nonce=str(nonce),
272
+ deadline=str(deadline),
273
+ witness=witness,
274
+ )
275
+
276
+ return SignatureData(
277
+ domain=domain,
278
+ types=types,
279
+ primary_type="PermitWitnessTransferFrom",
280
+ message=message,
281
+ )
282
+
283
+
284
+ async def sign_permit2(
285
+ eth: AsyncEth,
286
+ account: LocalAccount,
287
+ wallet_address: str,
288
+ src_amount: int,
289
+ quote_data: dict[str, Any],
290
+ ) -> tuple[SignatureData, SignedMessage]:
291
+ from_chain_id = quote_data["src_token"]["chain_id"]
292
+ from_address = quote_data["src_token"]["address"]
293
+ to_chain_id = quote_data["dst_token"]["chain_id"]
294
+ to_address = quote_data["dst_token"]["address"]
295
+
296
+ order_data = quote_data["order_data"]
297
+ dst_amount = int(order_data["parameters"]["dst_quantity"])
298
+
299
+ to_sign = await prepare_data_for_signature(
300
+ eth,
301
+ ChainData(
302
+ chain_id=from_chain_id,
303
+ token=TokenData(address=from_address, value=src_amount),
304
+ ),
305
+ ChainData(
306
+ chain_id=to_chain_id,
307
+ token=TokenData(address=to_address, value=dst_amount),
308
+ ),
309
+ wallet_address,
310
+ Quote(order_data=order_data),
311
+ )
312
+ # print(
313
+ # "Signing the following full message:",
314
+ # json.dumps(to_sign.model_dump(mode="json", by_alias=True)),
315
+ # )
316
+ signature = account.sign_typed_data(
317
+ full_message=to_sign.model_dump(mode="json", by_alias=True)
318
+ )
319
+ return (to_sign, signature)
320
+
321
+
322
+ async def approve_permit2(
323
+ w3: AsyncWeb3[P],
324
+ account: LocalAccount,
325
+ chain: ChainID,
326
+ token_address: str,
327
+ required_quantity: int,
328
+ maxGas: int = 100000,
329
+ ):
330
+ wallet_address = account.address
331
+
332
+ erc20 = get_erc20_contract(w3, token_address)
333
+ permit2_contract = _PERMIT2_CONTRACT_ADDRESSES.get(chain)
334
+ current_allowance = await erc20.functions.allowance(
335
+ wallet_address, permit2_contract
336
+ ).call()
337
+ if current_allowance < required_quantity:
338
+ logger.info(
339
+ f"Approving {token_address}: allowance={current_allowance}, required={required_quantity}"
340
+ )
341
+ approve_fn = erc20.functions.approve(permit2_contract, 2**256 - 1)
342
+ tx = await approve_fn.build_transaction(
343
+ {
344
+ "from": wallet_address,
345
+ "nonce": await w3.eth.get_transaction_count(
346
+ w3.to_checksum_address(wallet_address)
347
+ ),
348
+ "gas": maxGas, # Adjust as needed
349
+ "gasPrice": await w3.eth.gas_price,
350
+ }
351
+ )
352
+
353
+ # Sign and send transaction
354
+ signed_tx: SignedTransaction = account.sign_transaction(tx.__dict__)
355
+ tx_hash = await w3.eth.send_raw_transaction(signed_tx.raw_transaction)
356
+
357
+ logger.debug(f"→ Approval tx hash: {tx_hash.hex()}")
358
+ return tx_hash.hex()
359
+
360
+
361
+ async def create_order(
362
+ w3: AsyncWeb3[P],
363
+ account: LocalAccount,
364
+ src_chain: ChainID,
365
+ src_token: str,
366
+ dst_chain: ChainID,
367
+ dst_token: str,
368
+ raw_amount: int,
369
+ to_address: str | None = None,
370
+ ):
371
+ if not to_address:
372
+ to_address = account.address
373
+ q = await get_quote(
374
+ account.address,
375
+ to_address,
376
+ src_chain,
377
+ src_token,
378
+ dst_chain,
379
+ dst_token,
380
+ raw_amount,
381
+ )
382
+ await approve_permit2(w3, account, src_chain, src_token, raw_amount)
383
+ # print("Quote: ", json.dumps(q))
384
+ return await sign_permit2(w3.eth, account, account.address, raw_amount, q)
tristero/py.typed ADDED
File without changes
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.3
2
+ Name: tristero
3
+ Version: 0.1.4
4
+ Summary: Library for trading on Tristero
5
+ Author: pty1
6
+ Author-email: pty1 <pty11@proton.me>
7
+ Requires-Dist: eth-account>=0.13.7
8
+ Requires-Dist: httpx>=0.28.1
9
+ Requires-Dist: pydantic>=2.12.4
10
+ Requires-Dist: tenacity>=9.1.2
11
+ Requires-Dist: web3>=7.14.0
12
+ Requires-Dist: websockets>=15.0.1
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Tristero
17
+ [![PyPI version](https://badge.fury.io/py/tristero.svg)](https://badge.fury.io/py/tristero)
18
+ [![Python Support](https://img.shields.io/pypi/pyversions/tristero.svg)](https://pypi.org/project/tristero/)
19
+
20
+ This repository is home to Tristero's trading library.
21
+
22
+ ### Installation
23
+ ```
24
+ pip install tristero
25
+ ```
26
+
27
+ ### Quick Start
28
+
29
+ Execute a cross-chain swap in just a few lines:
30
+
31
+ ```py
32
+ import os
33
+ from tristero.client import TokenSpec, execute_swap
34
+ from eth_account import Account
35
+ from web3 import AsyncWeb3
36
+ from tristero.api import ChainID
37
+
38
+ private_key = os.getenv("EVM_PRIVATE_KEY")
39
+ account = Account.from_key(private_key)
40
+ w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider("https://arbitrum-one-rpc.publicnode.com"))
41
+
42
+ result = await execute_swap(
43
+ w3=w3,
44
+ account=account,
45
+ src_t=TokenSpec(chain_id=ChainID.arbitrum, token_address="0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"), # USDT
46
+ dst_t=TokenSpec(chain_id=ChainID.base, token_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), # USDC
47
+ raw_amount=10000000 # Raw token amount (multiply by 10^decimals)
48
+ )
49
+ ```
50
+
51
+ ### How it works
52
+
53
+ Tristero swaps happen in two steps:
54
+ - **Quote & Sign** - Request a quote from the server and sign it with your private key
55
+ - **Submit & Fill** - Submit the signed order to be filled at a later date
56
+
57
+ This library provides both high-level convenience functions and lower-level components for precise control.
58
+
59
+ ### API Reference
60
+
61
+ #### Execute Full Swap
62
+ `execute_swap` handles the entire workflow automatically: quoting, signing, submitting, and monitoring.
63
+ ```py
64
+ from tristero.swap import execute_swap, TokenSpec
65
+ from web3 import AsyncWeb3
66
+ from eth_account.signers.local import LocalAccount
67
+
68
+ w3 = AsyncWeb3(...) # Your Web3 provider
69
+ account: LocalAccount = ... # Your account
70
+
71
+ result = await execute_swap(
72
+ w3=w3,
73
+ account=account,
74
+ src_t=TokenSpec(chain_id=ChainID.ethereum, token_address="0xA0b8..."),
75
+ dst_t=TokenSpec(chain_id=ChainID.arbitrum, token_address="0xaf88..."),
76
+ raw_amount=10*(10**18),
77
+ retry=True,
78
+ timeout=300.0 # 5 minutes
79
+ )
80
+ ```
81
+
82
+ #### Requesting a quote
83
+
84
+ `get_quote` requests a quote for a particular swap, letting you see output amounts and gas fees.
85
+
86
+ ```py
87
+ from tristero.api import get_quote, ChainID
88
+
89
+ quote = await get_quote(
90
+ from_wallet="0x1234...", # Source wallet address
91
+ to_wallet="0x5678...", # Destination wallet address
92
+ from_chain_id=ChainID.ethereum, # Source chain
93
+ from_address="0xA0b8...", # Source token address (or "native")
94
+ to_chain_id=ChainID.arbitrum, # Destination chain
95
+ to_address="0xaf88...", # Destination token address (or "native")
96
+ amount=10*(10**18), # Amount in smallest unit (wei)
97
+ )
98
+ ```
99
+
100
+ #### Creating a signed order
101
+ `create_order` creates and signs an order without submitting to be filled.
102
+
103
+ ```py
104
+ from tristero.api import get_quote, ChainID
105
+
106
+ w3 = AsyncWeb3(...) # Your Web3 provider
107
+ account: LocalAccount = ... # Your account
108
+
109
+ data, sig = await create_order(
110
+ w3,
111
+ account,
112
+ from_chain_id=ChainID.ethereum,
113
+ from_address="0xA0b8...",
114
+ to_chain_id=ChainID.arbitrum,
115
+ to_address="0xaf88...",
116
+ raw_amount=10*(10**18),
117
+ )
118
+
119
+ ```
120
+
121
+ #### Submit order
122
+
123
+ `fill_order` submits a signed order for execution.
124
+
125
+ ```py
126
+ from tristero.api import fill_order
127
+
128
+ data, sig = ... # from earlier
129
+
130
+ response = await fill_order(
131
+ signature=str(sig.signature.to_0x_hex()),
132
+ domain=data.domain.model_dump(by_alias=True, mode="json"),
133
+ message=data.message.model_dump(by_alias=True, mode="json"),
134
+ )
135
+
136
+ order_id = response['id']
137
+ ```
138
+
139
+ #### Subscribing for updates
140
+
141
+ Orders can be monitored for changes and status live
142
+
143
+ ```py
144
+ from tristero.api import poll_updates
145
+ import json
146
+
147
+ ws = await poll_updates(order_id)
148
+
149
+ async for msg in ws:
150
+ update = json.loads(msg)
151
+ print(f"Completed: {update['completed']}")
152
+ print(f"Failed: {update['failed']}")
153
+
154
+ if update["completed"] or update["failed"]:
155
+ await ws.close()
156
+ break
157
+ ```
@@ -0,0 +1,11 @@
1
+ tristero/__init__.py,sha256=Jmbdg3LwOD4rrO0eerQTmn-wO-Sr4xRZ2r_RtM4iNDg,315
2
+ tristero/api.py,sha256=l4Qk_MJnu77bOjiqIdS7DYQbAg-JvYWBYpSmpeyFXEU,5507
3
+ tristero/client.py,sha256=DEcupVyEbPf78JDu-FrG8a60JlNQM0B10BsfieH8ddE,4073
4
+ tristero/config.py,sha256=_0PP2gufvq_t-gdKqDrxht2BxoWpNiGXk4m_u57WOYY,530
5
+ tristero/files/erc20_abi.json,sha256=jvsJ6aCwhMcmo3Yy1ajt5lPl_nTRg7tv-tGj87xzTOg,12800
6
+ tristero/files/permit2_abi.json,sha256=NV0AUUA9kqFPk56njvRRzUyjBhrBncKIMd3PrSH0LCc,17817
7
+ tristero/permit2.py,sha256=pOqNNZjd78QTHxhAPXjyN5NxBbXd5hF4o0pWP8WDvco,11329
8
+ tristero/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ tristero-0.1.4.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
10
+ tristero-0.1.4.dist-info/METADATA,sha256=WSV-y4KXNWNea8NHCUw2geHBDyUjkWBAwKgVp_NodYE,4389
11
+ tristero-0.1.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any