cryptointerface 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.
cryptointerface/dex.py ADDED
@@ -0,0 +1,892 @@
1
+ from web3 import Web3
2
+ from .providers.infura import Infura
3
+ from .periphery.dex_contracts import get_dex_contracts, _load, get_dex_architecture
4
+ from .periphery.db import _init_tables, insert_data
5
+ import polars as pl
6
+
7
+ # Minimal ABIs — only the functions needed for pool/pair lookups.
8
+ _V2_FACTORY_ABI = [
9
+ {
10
+ "inputs": [
11
+ {"internalType": "address", "name": "tokenA", "type": "address"},
12
+ {"internalType": "address", "name": "tokenB", "type": "address"},
13
+ ],
14
+ "name": "getPair",
15
+ "outputs": [{"internalType": "address", "name": "pair", "type": "address"}],
16
+ "stateMutability": "view",
17
+ "type": "function",
18
+ }
19
+ ]
20
+
21
+ _V3_FACTORY_ABI = [
22
+ {
23
+ "inputs": [
24
+ {"internalType": "address", "name": "tokenA", "type": "address"},
25
+ {"internalType": "address", "name": "tokenB", "type": "address"},
26
+ {"internalType": "uint24", "name": "fee", "type": "uint24"},
27
+ ],
28
+ "name": "getPool",
29
+ "outputs": [{"internalType": "address", "name": "pool", "type": "address"}],
30
+ "stateMutability": "view",
31
+ "type": "function",
32
+ }
33
+ ]
34
+
35
+ _ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
36
+
37
+ _V2_PAIR_ABI = [
38
+ {
39
+ "inputs": [],
40
+ "name": "getReserves",
41
+ "outputs": [
42
+ {"internalType": "uint112", "name": "reserve0", "type": "uint112"},
43
+ {"internalType": "uint112", "name": "reserve1", "type": "uint112"},
44
+ {"internalType": "uint32", "name": "blockTimestampLast", "type": "uint32"},
45
+ ],
46
+ "stateMutability": "view",
47
+ "type": "function",
48
+ }
49
+ ]
50
+
51
+ _V2_ROUTER_ABI = [
52
+ {
53
+ "inputs": [
54
+ {"internalType": "uint256", "name": "amountIn", "type": "uint256"},
55
+ {"internalType": "uint256", "name": "amountOutMin", "type": "uint256"},
56
+ {"internalType": "address[]", "name": "path", "type": "address[]"},
57
+ {"internalType": "address", "name": "to", "type": "address"},
58
+ {"internalType": "uint256", "name": "deadline", "type": "uint256"},
59
+ ],
60
+ "name": "swapExactTokensForTokens",
61
+ "outputs": [
62
+ {"internalType": "uint256[]", "name": "amounts", "type": "uint256[]"}
63
+ ],
64
+ "stateMutability": "nonpayable",
65
+ "type": "function",
66
+ }
67
+ ]
68
+
69
+ _V3_ROUTER_ABI = [
70
+ {
71
+ "inputs": [
72
+ {
73
+ "components": [
74
+ {"internalType": "address", "name": "tokenIn", "type": "address"},
75
+ {"internalType": "address", "name": "tokenOut", "type": "address"},
76
+ {"internalType": "uint24", "name": "fee", "type": "uint24"},
77
+ {"internalType": "address", "name": "recipient", "type": "address"},
78
+ {"internalType": "uint256", "name": "deadline", "type": "uint256"},
79
+ {"internalType": "uint256", "name": "amountIn", "type": "uint256"},
80
+ {
81
+ "internalType": "uint256",
82
+ "name": "amountOutMinimum",
83
+ "type": "uint256",
84
+ },
85
+ {
86
+ "internalType": "uint160",
87
+ "name": "sqrtPriceLimitX96",
88
+ "type": "uint160",
89
+ },
90
+ ],
91
+ "internalType": "struct ISwapRouter.ExactInputSingleParams",
92
+ "name": "params",
93
+ "type": "tuple",
94
+ }
95
+ ],
96
+ "name": "exactInputSingle",
97
+ "outputs": [
98
+ {"internalType": "uint256", "name": "amountOut", "type": "uint256"}
99
+ ],
100
+ "stateMutability": "payable",
101
+ "type": "function",
102
+ }
103
+ ]
104
+
105
+ _ALGEBRA_FACTORY_ABI = [
106
+ {
107
+ "inputs": [
108
+ {"internalType": "address", "name": "tokenA", "type": "address"},
109
+ {"internalType": "address", "name": "tokenB", "type": "address"},
110
+ ],
111
+ "name": "poolByPair",
112
+ "outputs": [{"internalType": "address", "name": "pool", "type": "address"}],
113
+ "stateMutability": "view",
114
+ "type": "function",
115
+ }
116
+ ]
117
+
118
+ _ALGEBRA_ROUTER_ABI = [
119
+ {
120
+ "inputs": [
121
+ {
122
+ "components": [
123
+ {"internalType": "address", "name": "tokenIn", "type": "address"},
124
+ {"internalType": "address", "name": "tokenOut", "type": "address"},
125
+ {"internalType": "address", "name": "recipient", "type": "address"},
126
+ {"internalType": "uint256", "name": "deadline", "type": "uint256"},
127
+ {"internalType": "uint256", "name": "amountIn", "type": "uint256"},
128
+ {
129
+ "internalType": "uint256",
130
+ "name": "amountOutMinimum",
131
+ "type": "uint256",
132
+ },
133
+ {
134
+ "internalType": "uint160",
135
+ "name": "limitSqrtPrice",
136
+ "type": "uint160",
137
+ },
138
+ ],
139
+ "internalType": "struct ISwapRouter.ExactInputSingleParams",
140
+ "name": "params",
141
+ "type": "tuple",
142
+ }
143
+ ],
144
+ "name": "exactInputSingle",
145
+ "outputs": [
146
+ {"internalType": "uint256", "name": "amountOut", "type": "uint256"}
147
+ ],
148
+ "stateMutability": "payable",
149
+ "type": "function",
150
+ }
151
+ ]
152
+
153
+ _SOLIDLY_FACTORY_ABI = [
154
+ {
155
+ "inputs": [
156
+ {"internalType": "address", "name": "tokenA", "type": "address"},
157
+ {"internalType": "address", "name": "tokenB", "type": "address"},
158
+ {"internalType": "bool", "name": "stable", "type": "bool"},
159
+ ],
160
+ "name": "getPool",
161
+ "outputs": [{"internalType": "address", "name": "pool", "type": "address"}],
162
+ "stateMutability": "view",
163
+ "type": "function",
164
+ }
165
+ ]
166
+
167
+ _SOLIDLY_ROUTER_ABI = [
168
+ {
169
+ "inputs": [
170
+ {"internalType": "uint256", "name": "amountIn", "type": "uint256"},
171
+ {"internalType": "uint256", "name": "amountOutMin", "type": "uint256"},
172
+ {
173
+ "components": [
174
+ {"internalType": "address", "name": "from", "type": "address"},
175
+ {"internalType": "address", "name": "to", "type": "address"},
176
+ {"internalType": "bool", "name": "stable", "type": "bool"},
177
+ ],
178
+ "internalType": "struct IRouter.route[]",
179
+ "name": "routes",
180
+ "type": "tuple[]",
181
+ },
182
+ {"internalType": "address", "name": "to", "type": "address"},
183
+ {"internalType": "uint256", "name": "deadline", "type": "uint256"},
184
+ ],
185
+ "name": "swapExactTokensForTokens",
186
+ "outputs": [
187
+ {"internalType": "uint256[]", "name": "amounts", "type": "uint256[]"}
188
+ ],
189
+ "stateMutability": "nonpayable",
190
+ "type": "function",
191
+ }
192
+ ]
193
+
194
+ _V3_POOL_ABI = [
195
+ {
196
+ "inputs": [],
197
+ "name": "slot0",
198
+ "outputs": [
199
+ {"internalType": "uint160", "name": "sqrtPriceX96", "type": "uint160"},
200
+ {"internalType": "int24", "name": "tick", "type": "int24"},
201
+ {"internalType": "uint16", "name": "observationIndex", "type": "uint16"},
202
+ {
203
+ "internalType": "uint16",
204
+ "name": "observationCardinality",
205
+ "type": "uint16",
206
+ },
207
+ {
208
+ "internalType": "uint16",
209
+ "name": "observationCardinalityNext",
210
+ "type": "uint16",
211
+ },
212
+ {"internalType": "uint8", "name": "feeProtocol", "type": "uint8"},
213
+ {"internalType": "bool", "name": "unlocked", "type": "bool"},
214
+ ],
215
+ "stateMutability": "view",
216
+ "type": "function",
217
+ }
218
+ ]
219
+
220
+
221
+ def _get_protocol(dex_name: str) -> str:
222
+ """Return the protocol string from dex_architecture.json, falling back to name-based inference."""
223
+ arch = get_dex_architecture(dex_name)
224
+ if arch:
225
+ return arch["protocol"]
226
+ # Fallback for DEXes not yet in dex_architecture.json
227
+ if "v3" in dex_name.lower():
228
+ return "uniswap_v3"
229
+ return "uniswap_v2"
230
+
231
+
232
+ def _get_fee_tiers(dex_name: str) -> list[int]:
233
+ """Return the fee tiers for a DEX, falling back to standard Uniswap V3 tiers."""
234
+ arch = get_dex_architecture(dex_name)
235
+ if arch and arch.get("fee_tiers"):
236
+ return arch["fee_tiers"]
237
+ return [500, 3000, 100, 10000]
238
+
239
+
240
+ def _sort_tokens(token_a: str, token_b: str) -> tuple[str, str]:
241
+ """
242
+ Return (token0, token1) in canonical Uniswap order (lower address first).
243
+ Both inputs are normalised to checksum addresses before comparison.
244
+ """
245
+ a = Web3.to_checksum_address(token_a)
246
+ b = Web3.to_checksum_address(token_b)
247
+ return (a, b) if int(a, 16) < int(b, 16) else (b, a)
248
+
249
+
250
+ def _resolve_pool_table(dex_name: str) -> str:
251
+ """Return the pool table name for a given DEX based on its architecture version."""
252
+ arch = get_dex_architecture(dex_name)
253
+ if arch:
254
+ return "pools_v3" if arch["version"] == 3 else "pools_v2"
255
+ if "v3" in dex_name.lower():
256
+ return "pools_v3"
257
+ if "v2" in dex_name.lower():
258
+ return "pools_v2"
259
+ raise ValueError(
260
+ f"Cannot resolve pool table for '{dex_name}': not found in dex_architecture.json."
261
+ )
262
+
263
+
264
+ def create_dex_mapping(dex_names: list[str], chain_ids: list[str], infura_obj) -> dict:
265
+ if isinstance(dex_names, str):
266
+ dex_names = [dex_names]
267
+ if isinstance(chain_ids, str):
268
+ chain_ids = [chain_ids]
269
+ mapping = {}
270
+ for d in dex_names:
271
+ for _id in chain_ids:
272
+ mapping[d] = Dex(d, _id, infura_obj.get_url(_id))
273
+ return mapping
274
+
275
+
276
+ class Dex:
277
+ def __init__(self, dex_name: str, chain_id: str, rpc_url: str = None):
278
+ self.name = dex_name
279
+ self.chain_id = chain_id
280
+ self.rpc_url = rpc_url
281
+ self.interface = DexInterface(rpc_url=rpc_url)
282
+ self._factory = None
283
+ self._router = None
284
+
285
+ @property
286
+ def factory(self):
287
+ if self._factory is None:
288
+ self._factory = self.interface.get_factory(self.name, self.chain_id)
289
+ return self._factory
290
+
291
+ @property
292
+ def router(self):
293
+ if self._router is None:
294
+ self._router = self.interface.get_router(self.name, self.chain_id)
295
+ return self._router
296
+
297
+ def pool_address(
298
+ self, token_a_address: str, token_b_address: str, fee_tier: str = None
299
+ ):
300
+ address = self.interface.get_pool_address(
301
+ token_a=token_a_address,
302
+ token_b=token_b_address,
303
+ dex_name=self.name,
304
+ chain_id=self.chain_id,
305
+ fee=fee_tier,
306
+ )
307
+ return address
308
+
309
+ def get_price(
310
+ self, token_a_address: str, token_b_address: str, fee_tier: int = None
311
+ ):
312
+ price = self.interface.get_price(
313
+ token_a_address,
314
+ token_b_address,
315
+ dex_name=self.name,
316
+ chain_id=self.chain_id,
317
+ fee=fee_tier,
318
+ )
319
+ return price
320
+
321
+
322
+ class DexInterface:
323
+ def __init__(self, rpc_url: str, debug: bool = True):
324
+ self.w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 10}))
325
+ self.dex_contracts = None
326
+ self.debug = debug
327
+
328
+ @property
329
+ def conn(self):
330
+ return _init_tables()
331
+
332
+ def set_dex_contracts(self):
333
+ self.dex_contracts = _load()
334
+
335
+ def get_dex_contracts(self):
336
+ if self.dex_contracts is None:
337
+ self.set_dex_contracts()
338
+ return self.dex_contracts
339
+
340
+ def get_router(self, dex_name: str, chain_id: str):
341
+ data = self.get_dex_contracts()
342
+ return self._get(
343
+ dex_name=dex_name, chain_id=chain_id, data=data, contract_type="router"
344
+ )
345
+
346
+ def get_factory(self, dex_name: str, chain_id: str):
347
+ data = self.get_dex_contracts()
348
+ return self._get(
349
+ dex_name=dex_name, chain_id=chain_id, data=data, contract_type="factory"
350
+ )
351
+
352
+ def get_pool_address(
353
+ self,
354
+ token_a: str,
355
+ token_b: str,
356
+ dex_name: str,
357
+ chain_id: int,
358
+ fee: int = None,
359
+ ) -> str | None:
360
+ token_0, token_1 = _sort_tokens(token_a, token_b)
361
+ token_0 = Web3.to_checksum_address(token_0)
362
+ token_1 = Web3.to_checksum_address(token_1)
363
+ is_v3 = _get_protocol(dex_name) in ("uniswap_v3", "algebra_v3")
364
+ if is_v3:
365
+ cached = self.read_pool_v3(dex_name, chain_id, token_0, token_1, fee)
366
+ if not cached.is_empty():
367
+ return cached["address"][0]
368
+ result = self._fetch_pool_address(
369
+ token_0, token_1, dex_name, str(chain_id), fee
370
+ )
371
+ if result is not None:
372
+ address, matched_fee = result
373
+ self.insert_pool_v3(
374
+ dex_name, chain_id, token_0, token_1, matched_fee, address
375
+ )
376
+ return address
377
+ else:
378
+ cached = self.read_pool_v2(dex_name, chain_id, token_0, token_1)
379
+ if not cached.is_empty():
380
+ return cached["address"][0]
381
+ result = self._fetch_pool_address(token_0, token_1, dex_name, str(chain_id))
382
+ if result is not None:
383
+ address, _ = result
384
+ self.insert_pool_v2(dex_name, chain_id, token_0, token_1, address)
385
+ return address
386
+
387
+ return None
388
+
389
+ def _fetch_pool_address(
390
+ self,
391
+ token_0: str,
392
+ token_1: str,
393
+ dex_name: str,
394
+ chain_id: str,
395
+ fee: int = None,
396
+ ) -> tuple[str, int | None] | None:
397
+ """
398
+ Fetch pool address from chain. Returns (address, fee) on success, None if not found.
399
+ Tokens must already be sorted before calling this method.
400
+ fee is unused for algebra_v3 and solidly_v2 (stored as 0).
401
+ """
402
+ if self.debug:
403
+ print(f"Fetching pool address from: {dex_name}")
404
+ factory_address = self.get_factory(dex_name, chain_id)
405
+ if factory_address is None:
406
+ return None
407
+
408
+ token_0 = Web3.to_checksum_address(token_0)
409
+ token_1 = Web3.to_checksum_address(token_1)
410
+ factory = Web3.to_checksum_address(factory_address)
411
+ protocol = _get_protocol(dex_name)
412
+
413
+ try:
414
+ if protocol == "uniswap_v3":
415
+ contract = self.w3.eth.contract(address=factory, abi=_V3_FACTORY_ABI)
416
+ fee_tiers = [fee] if fee is not None else _get_fee_tiers(dex_name)
417
+ for tier in fee_tiers:
418
+ pool = contract.functions.getPool(token_0, token_1, tier).call()
419
+ if pool != _ZERO_ADDRESS:
420
+ if self.debug:
421
+ print(f"Found pool at fee tier {tier}: {pool}")
422
+ return pool, tier
423
+ if self.debug:
424
+ print(f"No pool found for {token_0}/{token_1} on {dex_name} (chain {chain_id})")
425
+ return None
426
+
427
+ elif protocol == "algebra_v3":
428
+ contract = self.w3.eth.contract(address=factory, abi=_ALGEBRA_FACTORY_ABI)
429
+ pool = contract.functions.poolByPair(token_0, token_1).call()
430
+ if pool == _ZERO_ADDRESS:
431
+ if self.debug:
432
+ print(f"No pool found for {token_0}/{token_1} on {dex_name} (chain {chain_id})")
433
+ return None
434
+ if self.debug:
435
+ print(f"Found Algebra pool: {pool}")
436
+ return pool, 0
437
+
438
+ elif protocol == "solidly_v2":
439
+ contract = self.w3.eth.contract(address=factory, abi=_SOLIDLY_FACTORY_ABI)
440
+ for stable in (False, True):
441
+ pool = contract.functions.getPool(token_0, token_1, stable).call()
442
+ if pool != _ZERO_ADDRESS:
443
+ if self.debug:
444
+ print(f"Found Solidly pool (stable={stable}): {pool}")
445
+ return pool, None
446
+ if self.debug:
447
+ print(f"No pool found for {token_0}/{token_1} on {dex_name} (chain {chain_id})")
448
+ return None
449
+
450
+ else: # uniswap_v2
451
+ contract = self.w3.eth.contract(address=factory, abi=_V2_FACTORY_ABI)
452
+ pair = contract.functions.getPair(token_0, token_1).call()
453
+ if pair == _ZERO_ADDRESS:
454
+ if self.debug:
455
+ print(f"No pair found for {token_0}/{token_1} on {dex_name} (chain {chain_id})")
456
+ return None
457
+ return pair, None
458
+
459
+ except Exception as e:
460
+ if self.debug:
461
+ print(f"Contract call failed for {dex_name} (chain {chain_id}): {e}")
462
+ return None
463
+
464
+ ################## Insert ##################
465
+ def insert_pool_v2(
466
+ self, dex: str, chain_id: int, token_0: str, token_1: str, address: str
467
+ ) -> None:
468
+ df = pl.DataFrame(
469
+ {
470
+ "dex": [dex],
471
+ "chain_id": [chain_id],
472
+ "token_0": [token_0],
473
+ "token_1": [token_1],
474
+ "address": [address],
475
+ }
476
+ )
477
+ insert_data(
478
+ df,
479
+ db_cols=["dex", "chain_id", "token_0", "token_1", "address"],
480
+ table_name="pools_v2",
481
+ conn=self.conn,
482
+ pk_cols=["dex", "chain_id", "token_0", "token_1"],
483
+ )
484
+
485
+ def insert_pool_v3(
486
+ self,
487
+ dex: str,
488
+ chain_id: int,
489
+ token_0: str,
490
+ token_1: str,
491
+ fee: int,
492
+ address: str,
493
+ ) -> None:
494
+ df = pl.DataFrame(
495
+ {
496
+ "dex": [dex],
497
+ "chain_id": [chain_id],
498
+ "token_0": [token_0],
499
+ "token_1": [token_1],
500
+ "fee": [fee],
501
+ "address": [address],
502
+ }
503
+ )
504
+ insert_data(
505
+ df,
506
+ db_cols=["dex", "chain_id", "token_0", "token_1", "fee", "address"],
507
+ table_name="pools_v3",
508
+ conn=self.conn,
509
+ pk_cols=["dex", "chain_id", "token_0", "token_1", "fee"],
510
+ )
511
+
512
+ ################## Read ##################
513
+ def read_pool_v2(
514
+ self, dex: str, chain_id: int, token_0: str, token_1: str
515
+ ) -> pl.DataFrame:
516
+ query = """
517
+ SELECT * FROM pools_v2
518
+ WHERE dex = ? AND chain_id = ? AND token_0 = ? AND token_1 = ?
519
+ """
520
+ return self.conn.execute(query, [dex, chain_id, token_0, token_1]).pl()
521
+
522
+ def read_pool_v3(
523
+ self, dex: str, chain_id: int, token_0: str, token_1: str, fee: int = None
524
+ ) -> pl.DataFrame:
525
+ if fee is not None:
526
+ query = """
527
+ SELECT * FROM pools_v3
528
+ WHERE dex = ? AND chain_id = ? AND token_0 = ? AND token_1 = ? AND fee = ?
529
+ """
530
+ return self.conn.execute(query, [dex, chain_id, token_0, token_1, fee]).pl()
531
+ else:
532
+ query = """
533
+ SELECT * FROM pools_v3
534
+ WHERE dex = ? AND chain_id = ? AND token_0 = ? AND token_1 = ?
535
+ """
536
+ return self.conn.execute(query, [dex, chain_id, token_0, token_1]).pl()
537
+
538
+ ################## Prices ##################
539
+ def get_price(
540
+ self,
541
+ token_a: str,
542
+ token_b: str,
543
+ dex_name: str,
544
+ chain_id: int,
545
+ fee: int = None,
546
+ ) -> float | None:
547
+ """
548
+ Return the price of token_a denominated in token_b.
549
+ (i.e. how much token_b you receive for 1 token_a)
550
+ """
551
+ token_0, token_1 = _sort_tokens(token_a, token_b)
552
+ pool_address = self.get_pool_address(token_0, token_1, dex_name, chain_id, fee)
553
+ if pool_address is None:
554
+ return None
555
+
556
+ decimals_0 = self._get_token_decimals(token_0, str(chain_id))
557
+ decimals_1 = self._get_token_decimals(token_1, str(chain_id))
558
+
559
+ protocol = _get_protocol(dex_name)
560
+ if protocol in ("uniswap_v3", "algebra_v3"):
561
+ price = self._get_v3_price(pool_address, decimals_0, decimals_1)
562
+ else:
563
+ price = self._get_v2_price(pool_address, decimals_0, decimals_1)
564
+
565
+ if price is None:
566
+ return None
567
+
568
+ # If the caller passed tokens in reverse order, invert the price.
569
+ token_a_cs = Web3.to_checksum_address(token_a)
570
+ if token_a_cs != token_0:
571
+ return 1 / price
572
+ return price
573
+
574
+ def _get_v2_price(
575
+ self, pool_address: str, decimals_0: int, decimals_1: int
576
+ ) -> float | None:
577
+ """
578
+ Derive price from V2 pair reserves.
579
+ Returns price of token0 in terms of token1.
580
+ """
581
+ contract = self.w3.eth.contract(
582
+ address=Web3.to_checksum_address(pool_address),
583
+ abi=_V2_PAIR_ABI,
584
+ )
585
+ reserve0, reserve1, _ = contract.functions.getReserves().call()
586
+ if reserve0 == 0:
587
+ return None
588
+ return (reserve1 / 10**decimals_1) / (reserve0 / 10**decimals_0)
589
+
590
+ def _get_v3_price(
591
+ self, pool_address: str, decimals_0: int, decimals_1: int
592
+ ) -> float | None:
593
+ """
594
+ Derive price from V3 pool sqrtPriceX96.
595
+ Returns price of token0 in terms of token1.
596
+ """
597
+ contract = self.w3.eth.contract(
598
+ address=Web3.to_checksum_address(pool_address),
599
+ abi=_V3_POOL_ABI,
600
+ )
601
+ sqrt_price_x96 = contract.functions.slot0().call()[0]
602
+ if sqrt_price_x96 == 0:
603
+ return None
604
+ price = (sqrt_price_x96 / (2**96)) ** 2
605
+ return price * (10**decimals_0 / 10**decimals_1)
606
+
607
+ def _get_token_decimals(self, address: str, chain_id: str) -> int:
608
+ """Look up token decimals from the tokens table. Defaults to 18 if not found."""
609
+ result = self.conn.execute(
610
+ "SELECT decimals FROM tokens WHERE LOWER(address) = ? AND chain_id = ?",
611
+ [address.lower(), chain_id],
612
+ ).fetchone()
613
+ if result and result[0] is not None:
614
+ return result[0]
615
+ if self.debug:
616
+ print(
617
+ f"Decimals not found for {address} on chain {chain_id}, defaulting to 18"
618
+ )
619
+ return 18
620
+
621
+ ################## Swaps ##################
622
+ def swap(
623
+ self,
624
+ token_in: str,
625
+ token_out: str,
626
+ amount_in: int,
627
+ dex_name: str,
628
+ chain_id: int,
629
+ sender: str,
630
+ fee: int = None,
631
+ amount_out_min: int = 0,
632
+ deadline: int = None,
633
+ ) -> dict | None:
634
+ """
635
+ Build a swap transaction for token_in -> token_out.
636
+ Returns an unsigned transaction dict ready for signing, or None if the pool is not found.
637
+ amount_in is in raw token units (i.e. already scaled by decimals).
638
+ """
639
+ if deadline is None:
640
+ import time
641
+
642
+ deadline = int(time.time()) + 300
643
+
644
+ token_in_cs = Web3.to_checksum_address(token_in)
645
+ token_out_cs = Web3.to_checksum_address(token_out)
646
+
647
+ protocol = _get_protocol(dex_name)
648
+
649
+ if protocol == "uniswap_v3":
650
+ pool_address = self.get_pool_address(
651
+ token_in_cs, token_out_cs, dex_name, chain_id, fee
652
+ )
653
+ if pool_address is None:
654
+ return None
655
+ token_0, token_1 = _sort_tokens(token_in_cs, token_out_cs)
656
+ cached = self.read_pool_v3(dex_name, chain_id, token_0, token_1, fee)
657
+ resolved_fee = (
658
+ fee
659
+ if fee is not None
660
+ else (cached["fee"][0] if not cached.is_empty() else None)
661
+ )
662
+ if resolved_fee is None:
663
+ return None
664
+ return self._swap_v3(
665
+ token_in_cs,
666
+ token_out_cs,
667
+ amount_in,
668
+ dex_name,
669
+ str(chain_id),
670
+ sender,
671
+ resolved_fee,
672
+ amount_out_min,
673
+ deadline,
674
+ )
675
+
676
+ elif protocol == "algebra_v3":
677
+ pool_address = self.get_pool_address(
678
+ token_in_cs, token_out_cs, dex_name, chain_id
679
+ )
680
+ if pool_address is None:
681
+ return None
682
+ return self._swap_algebra(
683
+ token_in_cs,
684
+ token_out_cs,
685
+ amount_in,
686
+ dex_name,
687
+ str(chain_id),
688
+ sender,
689
+ amount_out_min,
690
+ deadline,
691
+ )
692
+
693
+ elif protocol == "solidly_v2":
694
+ pool_address = self.get_pool_address(
695
+ token_in_cs, token_out_cs, dex_name, chain_id
696
+ )
697
+ if pool_address is None:
698
+ return None
699
+ return self._swap_solidly(
700
+ token_in_cs,
701
+ token_out_cs,
702
+ amount_in,
703
+ dex_name,
704
+ str(chain_id),
705
+ sender,
706
+ amount_out_min,
707
+ deadline,
708
+ )
709
+
710
+ else: # uniswap_v2
711
+ pool_address = self.get_pool_address(
712
+ token_in_cs, token_out_cs, dex_name, chain_id
713
+ )
714
+ if pool_address is None:
715
+ return None
716
+ return self._swap_v2(
717
+ token_in_cs,
718
+ token_out_cs,
719
+ amount_in,
720
+ dex_name,
721
+ str(chain_id),
722
+ sender,
723
+ amount_out_min,
724
+ deadline,
725
+ )
726
+
727
+ def _swap_v2(
728
+ self,
729
+ token_in: str,
730
+ token_out: str,
731
+ amount_in: int,
732
+ dex_name: str,
733
+ chain_id: str,
734
+ sender: str,
735
+ amount_out_min: int,
736
+ deadline: int,
737
+ ) -> dict | None:
738
+ """Build an unsigned V2 swapExactTokensForTokens transaction."""
739
+ router_address = self.get_router(dex_name, chain_id)
740
+ if router_address is None:
741
+ return None
742
+ router = self.w3.eth.contract(
743
+ address=Web3.to_checksum_address(router_address),
744
+ abi=_V2_ROUTER_ABI,
745
+ )
746
+ sender_cs = Web3.to_checksum_address(sender)
747
+ tx = router.functions.swapExactTokensForTokens(
748
+ amount_in,
749
+ amount_out_min,
750
+ [token_in, token_out],
751
+ sender_cs,
752
+ deadline,
753
+ ).build_transaction({"from": sender_cs})
754
+ return tx
755
+
756
+ def _swap_v3(
757
+ self,
758
+ token_in: str,
759
+ token_out: str,
760
+ amount_in: int,
761
+ dex_name: str,
762
+ chain_id: str,
763
+ sender: str,
764
+ fee: int,
765
+ amount_out_min: int,
766
+ deadline: int,
767
+ ) -> dict | None:
768
+ """Build an unsigned V3 exactInputSingle transaction."""
769
+ router_address = self.get_router(dex_name, chain_id)
770
+ if router_address is None:
771
+ return None
772
+ router = self.w3.eth.contract(
773
+ address=Web3.to_checksum_address(router_address),
774
+ abi=_V3_ROUTER_ABI,
775
+ )
776
+ sender_cs = Web3.to_checksum_address(sender)
777
+ tx = router.functions.exactInputSingle(
778
+ {
779
+ "tokenIn": token_in,
780
+ "tokenOut": token_out,
781
+ "fee": fee,
782
+ "recipient": sender_cs,
783
+ "deadline": deadline,
784
+ "amountIn": amount_in,
785
+ "amountOutMinimum": amount_out_min,
786
+ "sqrtPriceLimitX96": 0,
787
+ }
788
+ ).build_transaction({"from": sender_cs})
789
+ return tx
790
+
791
+ def _swap_algebra(
792
+ self,
793
+ token_in: str,
794
+ token_out: str,
795
+ amount_in: int,
796
+ dex_name: str,
797
+ chain_id: str,
798
+ sender: str,
799
+ amount_out_min: int,
800
+ deadline: int,
801
+ ) -> dict | None:
802
+ """Build an unsigned Algebra V3 exactInputSingle transaction (no fee field)."""
803
+ router_address = self.get_router(dex_name, chain_id)
804
+ if router_address is None:
805
+ return None
806
+ router = self.w3.eth.contract(
807
+ address=Web3.to_checksum_address(router_address),
808
+ abi=_ALGEBRA_ROUTER_ABI,
809
+ )
810
+ sender_cs = Web3.to_checksum_address(sender)
811
+ tx = router.functions.exactInputSingle(
812
+ {
813
+ "tokenIn": token_in,
814
+ "tokenOut": token_out,
815
+ "recipient": sender_cs,
816
+ "deadline": deadline,
817
+ "amountIn": amount_in,
818
+ "amountOutMinimum": amount_out_min,
819
+ "limitSqrtPrice": 0,
820
+ }
821
+ ).build_transaction({"from": sender_cs})
822
+ return tx
823
+
824
+ def _swap_solidly(
825
+ self,
826
+ token_in: str,
827
+ token_out: str,
828
+ amount_in: int,
829
+ dex_name: str,
830
+ chain_id: str,
831
+ sender: str,
832
+ amount_out_min: int,
833
+ deadline: int,
834
+ ) -> dict | None:
835
+ """
836
+ Build an unsigned Solidly swapExactTokensForTokens transaction.
837
+ Tries volatile route first; if no pool exists falls back to stable.
838
+ """
839
+ router_address = self.get_router(dex_name, chain_id)
840
+ if router_address is None:
841
+ return None
842
+ factory_address = self.get_factory(dex_name, chain_id)
843
+ factory = self.w3.eth.contract(
844
+ address=Web3.to_checksum_address(factory_address),
845
+ abi=_SOLIDLY_FACTORY_ABI,
846
+ )
847
+ # Determine whether the existing pool is stable or volatile.
848
+ stable = False
849
+ if (
850
+ factory.functions.getPool(token_in, token_out, False).call()
851
+ == _ZERO_ADDRESS
852
+ ):
853
+ stable = True
854
+
855
+ router = self.w3.eth.contract(
856
+ address=Web3.to_checksum_address(router_address),
857
+ abi=_SOLIDLY_ROUTER_ABI,
858
+ )
859
+ sender_cs = Web3.to_checksum_address(sender)
860
+ tx = router.functions.swapExactTokensForTokens(
861
+ amount_in,
862
+ amount_out_min,
863
+ [{"from": token_in, "to": token_out, "stable": stable}],
864
+ sender_cs,
865
+ deadline,
866
+ ).build_transaction({"from": sender_cs})
867
+ return tx
868
+
869
+ def _get(
870
+ self, dex_name: str, chain_id: str, data: dict, contract_type: str
871
+ ) -> str | None:
872
+ dex_data = data.get(dex_name)
873
+ if dex_data is None:
874
+ if self.debug:
875
+ print(f"Couldn't find dex: {dex_name}...")
876
+ return None
877
+
878
+ chain_data = dex_data.get(chain_id)
879
+ if chain_data is None:
880
+ if self.debug:
881
+ print(
882
+ f"Couldn't find chain_id: {chain_id}. Available: {list(dex_data.keys())}"
883
+ )
884
+ return None
885
+
886
+ contract_address = chain_data.get(contract_type)
887
+ if contract_address is None and self.debug:
888
+ print(
889
+ f"Couldn't find contract type: {contract_type}. Available: {list(chain_data.keys())}"
890
+ )
891
+
892
+ return contract_address