dexscreen 0.0.2__py3-none-any.whl → 0.0.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.
dexscreen/__init__.py CHANGED
@@ -5,6 +5,50 @@ A modern, stable, and reliable Python SDK for DexScreener API with HTTP support.
5
5
  """
6
6
 
7
7
  from .api.client import DexscreenerClient
8
+ from .core.exceptions import (
9
+ # API errors
10
+ APIError,
11
+ APILimitError,
12
+ AuthenticationError,
13
+ # Configuration errors
14
+ ConfigurationError,
15
+ ConnectionError,
16
+ DataFormatError,
17
+ # Base exceptions
18
+ DexscreenError,
19
+ FilterConfigError,
20
+ HttpConnectionError,
21
+ # HTTP errors
22
+ HttpError,
23
+ HttpRequestError,
24
+ HttpResponseParsingError,
25
+ HttpSessionError,
26
+ HttpTimeoutError,
27
+ InvalidAddressError,
28
+ InvalidChainError,
29
+ InvalidConfigError,
30
+ InvalidResponseError,
31
+ MissingConfigError,
32
+ MissingDataError,
33
+ # Network errors
34
+ NetworkError,
35
+ ProxyError,
36
+ RateLimitError,
37
+ ServerError,
38
+ StreamConnectionError,
39
+ StreamDataError,
40
+ # Streaming errors
41
+ StreamError,
42
+ StreamTimeoutError,
43
+ SubscriptionError,
44
+ TimeoutError,
45
+ # Validation errors
46
+ ValidationError,
47
+ # Utility functions
48
+ get_error_category,
49
+ is_retryable_error,
50
+ should_wait_before_retry,
51
+ )
8
52
  from .core.models import (
9
53
  BaseToken,
10
54
  Liquidity,
@@ -18,14 +62,57 @@ from .utils.filters import FilterConfig, FilterPresets
18
62
 
19
63
  __version__ = "1.0.0"
20
64
  __all__ = [
65
+ # API errors
66
+ "APIError",
67
+ "APILimitError",
68
+ "AuthenticationError",
69
+ # Core models and client
21
70
  "BaseToken",
71
+ # Configuration errors
72
+ "ConfigurationError",
73
+ "ConnectionError",
74
+ "DataFormatError",
75
+ # Base exceptions
76
+ "DexscreenError",
22
77
  "DexscreenerClient",
23
78
  "FilterConfig",
79
+ "FilterConfigError",
24
80
  "FilterPresets",
81
+ "HttpConnectionError",
82
+ # HTTP errors
83
+ "HttpError",
84
+ "HttpRequestError",
85
+ "HttpResponseParsingError",
86
+ "HttpSessionError",
87
+ "HttpTimeoutError",
88
+ "InvalidAddressError",
89
+ "InvalidChainError",
90
+ "InvalidConfigError",
91
+ "InvalidResponseError",
25
92
  "Liquidity",
93
+ "MissingConfigError",
94
+ "MissingDataError",
95
+ # Network errors
96
+ "NetworkError",
26
97
  "PairTransactionCounts",
27
98
  "PriceChangePeriods",
99
+ "ProxyError",
100
+ "RateLimitError",
101
+ "ServerError",
102
+ "StreamConnectionError",
103
+ "StreamDataError",
104
+ # Streaming errors
105
+ "StreamError",
106
+ "StreamTimeoutError",
107
+ "SubscriptionError",
108
+ "TimeoutError",
28
109
  "TokenPair",
29
110
  "TransactionCount",
111
+ # Validation errors
112
+ "ValidationError",
30
113
  "VolumeChangePeriods",
114
+ # Utility functions
115
+ "get_error_category",
116
+ "is_retryable_error",
117
+ "should_wait_before_retry",
31
118
  ]
dexscreen/api/client.py CHANGED
@@ -3,12 +3,26 @@ Simplified Dexscreener client with clean API
3
3
  """
4
4
 
5
5
  import asyncio
6
+ import logging
6
7
  from typing import Any, Callable, Optional, Union
7
8
 
9
+ from ..core.exceptions import (
10
+ HttpError,
11
+ )
8
12
  from ..core.http import HttpClientCffi
9
13
  from ..core.models import OrderInfo, TokenInfo, TokenPair
14
+ from ..core.validators import (
15
+ validate_address,
16
+ validate_addresses_list,
17
+ validate_chain_id,
18
+ validate_dict_config,
19
+ validate_string,
20
+ )
10
21
  from ..stream.polling import PollingStream
11
22
  from ..utils.filters import FilterConfig, TokenPairFilter
23
+ from ..utils.logging_config import get_contextual_logger, log_function_call, with_correlation_id
24
+
25
+ logger = logging.getLogger(__name__)
12
26
 
13
27
 
14
28
  class DexscreenerClient:
@@ -27,7 +41,17 @@ class DexscreenerClient:
27
41
  Args:
28
42
  impersonate: Browser to impersonate (default: None, uses random market-share based selection)
29
43
  client_kwargs: Optional kwargs to pass to curl_cffi clients.
44
+
45
+ Raises:
46
+ InvalidTypeError: If parameters have invalid types
47
+ InvalidParameterError: If impersonate value is invalid
30
48
  """
49
+ # Validate inputs
50
+ if impersonate is not None:
51
+ impersonate = validate_string(impersonate, "impersonate", 1, 50)
52
+
53
+ client_kwargs = validate_dict_config(client_kwargs, "client_kwargs", allow_none=True)
54
+
31
55
  # Setup client kwargs
32
56
  self.client_kwargs = client_kwargs or {}
33
57
  # Use provided impersonate or our custom realworld browser selection
@@ -54,33 +78,144 @@ class DexscreenerClient:
54
78
  # Filters for each subscription
55
79
  self._filters: dict[str, TokenPairFilter] = {}
56
80
 
81
+ # Enhanced logging
82
+ self.contextual_logger = get_contextual_logger(__name__)
83
+
84
+ init_context = {
85
+ "impersonate": impersonate,
86
+ "has_client_kwargs": bool(client_kwargs),
87
+ "client_kwargs_keys": list(client_kwargs.keys()) if client_kwargs else [],
88
+ }
89
+
90
+ self.contextual_logger.debug("DexscreenerClient initialized", context=init_context)
91
+
57
92
  # ========== Single Query Methods ==========
58
93
 
94
+ @with_correlation_id()
95
+ @log_function_call(log_args=True, log_result=False)
59
96
  def get_pair(self, address: str) -> Optional[TokenPair]:
60
- """Get a single token pair by address using search"""
61
- # Since the API requires chain ID, we use search as a workaround
62
- resp = self._client_300rpm.request("GET", f"latest/dex/search?q={address}")
63
- if resp is not None and isinstance(resp, dict) and "pairs" in resp and len(resp["pairs"]) > 0:
64
- # Return the first matching pair
65
- for pair in resp["pairs"]:
66
- if pair.get("pairAddress", "").lower() == address.lower():
67
- return TokenPair(**pair)
68
- # If no exact match, return the first result
69
- return TokenPair(**resp["pairs"][0])
70
- return None
97
+ """
98
+ Get a single token pair by address using search.
99
+
100
+ Args:
101
+ address: Token pair address to search for
102
+
103
+ Returns:
104
+ TokenPair object if found, None otherwise
105
+
106
+ Raises:
107
+ InvalidAddressError: If address format is invalid
108
+ HttpError: If HTTP request fails (connection, timeout, etc.)
109
+ """
110
+ # Validate input
111
+ address = validate_address(address)
112
+
113
+ search_context: dict[str, Any] = {
114
+ "operation": "get_pair",
115
+ "address": address[:10] + "..." if len(address) > 10 else address,
116
+ "method": "search_workaround",
117
+ }
118
+
119
+ self.contextual_logger.debug("Getting pair by address", context=search_context)
120
+
121
+ try:
122
+ # Since the API requires chain ID, we use search as a workaround
123
+ resp = self._client_300rpm.request("GET", f"latest/dex/search?q={address}")
124
+ if resp is not None and isinstance(resp, dict) and "pairs" in resp and len(resp["pairs"]) > 0:
125
+ pairs_found = len(resp["pairs"])
126
+ search_context["pairs_found"] = pairs_found
127
+
128
+ # Return the first matching pair
129
+ for pair in resp["pairs"]:
130
+ if pair.get("pairAddress", "").lower() == address.lower():
131
+ search_context["exact_match"] = True
132
+ self.contextual_logger.debug("Found exact pair match", context=search_context)
133
+ return TokenPair(**pair)
134
+
135
+ # If no exact match, return the first result
136
+ search_context["exact_match"] = False
137
+ search_context["using_first_result"] = True
138
+ self.contextual_logger.debug("No exact match, using first result", context=search_context)
139
+ return TokenPair(**resp["pairs"][0])
140
+ else:
141
+ search_context["pairs_found"] = 0
142
+ search_context["response_valid"] = resp is not None
143
+ self.contextual_logger.warning("No pairs found for address", context=search_context)
144
+ return None
145
+ except HttpError as e:
146
+ error_context = search_context.copy()
147
+ error_context["error_type"] = type(e).__name__
148
+ error_context["error_message"] = str(e)
149
+
150
+ self.contextual_logger.error(
151
+ "Failed to get pair for address: %s", str(e), context=error_context, exc_info=True
152
+ )
71
153
 
154
+ logger.error("Failed to get pair for address %s: %s", address, e) # Keep original logging
155
+ raise # Re-raise the HTTP error for caller to handle
156
+
157
+ @with_correlation_id()
158
+ @log_function_call(log_args=True, log_result=False)
72
159
  async def get_pair_async(self, address: str) -> Optional[TokenPair]:
73
- """Async version of get_pair"""
74
- # Since the API requires chain ID, we use search as a workaround
75
- resp = await self._client_300rpm.request_async("GET", f"latest/dex/search?q={address}")
76
- if resp is not None and isinstance(resp, dict) and "pairs" in resp and len(resp["pairs"]) > 0:
77
- # Return the first matching pair
78
- for pair in resp["pairs"]:
79
- if pair.get("pairAddress", "").lower() == address.lower():
80
- return TokenPair(**pair)
81
- # If no exact match, return the first result
82
- return TokenPair(**resp["pairs"][0])
83
- return None
160
+ """
161
+ Async version of get_pair.
162
+
163
+ Args:
164
+ address: Token pair address to search for
165
+
166
+ Returns:
167
+ TokenPair object if found, None otherwise
168
+
169
+ Raises:
170
+ InvalidAddressError: If address format is invalid
171
+ HttpError: If HTTP request fails (connection, timeout, etc.)
172
+ """
173
+ # Validate input
174
+ address = validate_address(address)
175
+
176
+ search_context: dict[str, Any] = {
177
+ "operation": "get_pair_async",
178
+ "address": address[:10] + "..." if len(address) > 10 else address,
179
+ "method": "search_workaround",
180
+ }
181
+
182
+ self.contextual_logger.debug("Getting pair by address (async)", context=search_context)
183
+
184
+ try:
185
+ # Since the API requires chain ID, we use search as a workaround
186
+ resp = await self._client_300rpm.request_async("GET", f"latest/dex/search?q={address}")
187
+ if resp is not None and isinstance(resp, dict) and "pairs" in resp and len(resp["pairs"]) > 0:
188
+ pairs_found = len(resp["pairs"])
189
+ search_context["pairs_found"] = pairs_found
190
+
191
+ # Return the first matching pair
192
+ for pair in resp["pairs"]:
193
+ if pair.get("pairAddress", "").lower() == address.lower():
194
+ search_context["exact_match"] = True
195
+ self.contextual_logger.debug("Found exact pair match (async)", context=search_context)
196
+ return TokenPair(**pair)
197
+
198
+ # If no exact match, return the first result
199
+ search_context["exact_match"] = False
200
+ search_context["using_first_result"] = True
201
+ self.contextual_logger.debug("No exact match, using first result (async)", context=search_context)
202
+ return TokenPair(**resp["pairs"][0])
203
+ else:
204
+ search_context["pairs_found"] = 0
205
+ search_context["response_valid"] = resp is not None
206
+ self.contextual_logger.warning("No pairs found for address (async)", context=search_context)
207
+ return None
208
+ except HttpError as e:
209
+ error_context = search_context.copy()
210
+ error_context["error_type"] = type(e).__name__
211
+ error_context["error_message"] = str(e)
212
+
213
+ self.contextual_logger.error(
214
+ "Failed to get pair for address (async): %s", str(e), context=error_context, exc_info=True
215
+ )
216
+
217
+ logger.error("Failed to get pair for address %s: %s", address, e) # Keep original logging
218
+ raise # Re-raise the HTTP error for caller to handle
84
219
 
85
220
  def get_pairs_by_pairs_addresses(self, chain_id: str, pair_addresses: list[str]) -> list[TokenPair]:
86
221
  """
@@ -96,14 +231,19 @@ class DexscreenerClient:
96
231
  List of TokenPair objects
97
232
 
98
233
  Raises:
99
- ValueError: If more than 30 pair addresses are provided
234
+ InvalidChainIdError: If chain ID is invalid
235
+ InvalidAddressError: If any address format is invalid
236
+ TooManyItemsError: If more than 30 pair addresses are provided
100
237
  """
238
+ # Validate inputs
239
+ chain_id = validate_chain_id(chain_id)
240
+ pair_addresses = validate_addresses_list(
241
+ pair_addresses, "pair_addresses", min_count=0, max_count=self.MAX_PAIRS_PER_REQUEST, chain_id=chain_id
242
+ )
243
+
101
244
  if not pair_addresses:
102
245
  return []
103
246
 
104
- if len(pair_addresses) > self.MAX_PAIRS_PER_REQUEST:
105
- raise ValueError(f"Maximum {self.MAX_PAIRS_PER_REQUEST} pair addresses allowed, got {len(pair_addresses)}")
106
-
107
247
  addresses_str = ",".join(pair_addresses)
108
248
  resp = self._client_300rpm.request("GET", f"latest/dex/pairs/{chain_id}/{addresses_str}")
109
249
  if resp is None:
@@ -126,14 +266,19 @@ class DexscreenerClient:
126
266
  List of TokenPair objects
127
267
 
128
268
  Raises:
129
- ValueError: If more than 30 pair addresses are provided
269
+ InvalidChainIdError: If chain ID is invalid
270
+ InvalidAddressError: If any address format is invalid
271
+ TooManyItemsError: If more than 30 pair addresses are provided
130
272
  """
273
+ # Validate inputs
274
+ chain_id = validate_chain_id(chain_id)
275
+ pair_addresses = validate_addresses_list(
276
+ pair_addresses, "pair_addresses", min_count=0, max_count=self.MAX_PAIRS_PER_REQUEST, chain_id=chain_id
277
+ )
278
+
131
279
  if not pair_addresses:
132
280
  return []
133
281
 
134
- if len(pair_addresses) > self.MAX_PAIRS_PER_REQUEST:
135
- raise ValueError(f"Maximum {self.MAX_PAIRS_PER_REQUEST} pair addresses allowed, got {len(pair_addresses)}")
136
-
137
282
  addresses_str = ",".join(pair_addresses)
138
283
  resp = await self._client_300rpm.request_async("GET", f"latest/dex/pairs/{chain_id}/{addresses_str}")
139
284
  if resp is None:
@@ -154,6 +299,11 @@ class DexscreenerClient:
154
299
 
155
300
  def search_pairs(self, query: str) -> list[TokenPair]:
156
301
  """Search for pairs by query"""
302
+ from ..core.validators import validate_query_string
303
+
304
+ # Validate query string
305
+ query = validate_query_string(query, max_length=100)
306
+
157
307
  resp = self._client_300rpm.request("GET", f"latest/dex/search?q={query}")
158
308
  if resp is not None and isinstance(resp, dict):
159
309
  return [TokenPair(**pair) for pair in resp.get("pairs", [])]
@@ -161,6 +311,11 @@ class DexscreenerClient:
161
311
 
162
312
  async def search_pairs_async(self, query: str) -> list[TokenPair]:
163
313
  """Async version of search_pairs"""
314
+ from ..core.validators import validate_query_string
315
+
316
+ # Validate query string
317
+ query = validate_query_string(query, max_length=100)
318
+
164
319
  resp = await self._client_300rpm.request_async("GET", f"latest/dex/search?q={query}")
165
320
  if resp is not None and isinstance(resp, dict):
166
321
  return [TokenPair(**pair) for pair in resp.get("pairs", [])]
@@ -210,6 +365,10 @@ class DexscreenerClient:
210
365
 
211
366
  def get_orders_paid_of_token(self, chain_id: str, token_address: str) -> list[OrderInfo]:
212
367
  """Get orders for a token"""
368
+ # Validate inputs
369
+ chain_id = validate_chain_id(chain_id)
370
+ token_address = validate_address(token_address, chain_id)
371
+
213
372
  resp = self._client_60rpm.request("GET", f"orders/v1/{chain_id}/{token_address}")
214
373
  if resp is not None:
215
374
  return [OrderInfo(**order) for order in resp]
@@ -217,6 +376,10 @@ class DexscreenerClient:
217
376
 
218
377
  async def get_orders_paid_of_token_async(self, chain_id: str, token_address: str) -> list[OrderInfo]:
219
378
  """Async version of get_orders_paid_of_token"""
379
+ # Validate inputs
380
+ chain_id = validate_chain_id(chain_id)
381
+ token_address = validate_address(token_address, chain_id)
382
+
220
383
  resp = await self._client_60rpm.request_async("GET", f"orders/v1/{chain_id}/{token_address}")
221
384
  if resp is not None:
222
385
  return [OrderInfo(**order) for order in resp]
@@ -224,6 +387,10 @@ class DexscreenerClient:
224
387
 
225
388
  def get_pairs_by_token_address(self, chain_id: str, token_address: str) -> list[TokenPair]:
226
389
  """Get all pairs for a single token address on a specific chain"""
390
+ # Validate inputs
391
+ chain_id = validate_chain_id(chain_id)
392
+ token_address = validate_address(token_address, chain_id)
393
+
227
394
  # Use the correct endpoint format: /tokens/v1/{chain}/{address}
228
395
  resp = self._client_300rpm.request("GET", f"tokens/v1/{chain_id}/{token_address}")
229
396
  if resp is None:
@@ -236,6 +403,10 @@ class DexscreenerClient:
236
403
 
237
404
  async def get_pairs_by_token_address_async(self, chain_id: str, token_address: str) -> list[TokenPair]:
238
405
  """Async version of get_pairs_by_token_address"""
406
+ # Validate inputs
407
+ chain_id = validate_chain_id(chain_id)
408
+ token_address = validate_address(token_address, chain_id)
409
+
239
410
  # Use the correct endpoint format: /tokens/v1/{chain}/{address}
240
411
  resp = await self._client_300rpm.request_async("GET", f"tokens/v1/{chain_id}/{token_address}")
241
412
  if resp is None:
@@ -261,16 +432,19 @@ class DexscreenerClient:
261
432
  List of TokenPair objects (maximum 30 pairs)
262
433
 
263
434
  Raises:
264
- ValueError: If more than 30 token addresses are provided
435
+ InvalidChainIdError: If chain ID is invalid
436
+ InvalidAddressError: If any address format is invalid
437
+ TooManyItemsError: If more than 30 token addresses are provided
265
438
  """
439
+ # Validate inputs
440
+ chain_id = validate_chain_id(chain_id)
441
+ token_addresses = validate_addresses_list(
442
+ token_addresses, "token_addresses", min_count=0, max_count=self.MAX_TOKENS_PER_REQUEST, chain_id=chain_id
443
+ )
444
+
266
445
  if not token_addresses:
267
446
  return []
268
447
 
269
- if len(token_addresses) > self.MAX_TOKENS_PER_REQUEST:
270
- raise ValueError(
271
- f"Maximum {self.MAX_TOKENS_PER_REQUEST} token addresses allowed, got {len(token_addresses)}"
272
- )
273
-
274
448
  if len(token_addresses) == 1:
275
449
  # For single token, use the single token method
276
450
  return self.get_pairs_by_token_address(chain_id, token_addresses[0])
@@ -297,6 +471,10 @@ class DexscreenerClient:
297
471
 
298
472
  def get_pools_by_token_address(self, chain_id: str, token_address: str) -> list[TokenPair]:
299
473
  """Get pools info using token-pairs/v1 endpoint (Pool endpoint)"""
474
+ # Validate inputs
475
+ chain_id = validate_chain_id(chain_id)
476
+ token_address = validate_address(token_address, chain_id)
477
+
300
478
  # Use the token-pairs/v1 endpoint
301
479
  resp = self._client_300rpm.request("GET", f"token-pairs/v1/{chain_id}/{token_address}")
302
480
  if resp is None:
@@ -309,6 +487,10 @@ class DexscreenerClient:
309
487
 
310
488
  async def get_pools_by_token_address_async(self, chain_id: str, token_address: str) -> list[TokenPair]:
311
489
  """Async version of get_pools_by_token_address"""
490
+ # Validate inputs
491
+ chain_id = validate_chain_id(chain_id)
492
+ token_address = validate_address(token_address, chain_id)
493
+
312
494
  # Use the token-pairs/v1 endpoint
313
495
  resp = await self._client_300rpm.request_async("GET", f"token-pairs/v1/{chain_id}/{token_address}")
314
496
  if resp is None:
@@ -334,16 +516,19 @@ class DexscreenerClient:
334
516
  List of TokenPair objects (maximum 30 pairs)
335
517
 
336
518
  Raises:
337
- ValueError: If more than 30 token addresses are provided
519
+ InvalidChainIdError: If chain ID is invalid
520
+ InvalidAddressError: If any address format is invalid
521
+ TooManyItemsError: If more than 30 token addresses are provided
338
522
  """
523
+ # Validate inputs
524
+ chain_id = validate_chain_id(chain_id)
525
+ token_addresses = validate_addresses_list(
526
+ token_addresses, "token_addresses", min_count=0, max_count=self.MAX_TOKENS_PER_REQUEST, chain_id=chain_id
527
+ )
528
+
339
529
  if not token_addresses:
340
530
  return []
341
531
 
342
- if len(token_addresses) > self.MAX_TOKENS_PER_REQUEST:
343
- raise ValueError(
344
- f"Maximum {self.MAX_TOKENS_PER_REQUEST} token addresses allowed, got {len(token_addresses)}"
345
- )
346
-
347
532
  if len(token_addresses) == 1:
348
533
  # For single token, use the single token method
349
534
  return await self.get_pairs_by_token_address_async(chain_id, token_addresses[0])
@@ -411,6 +596,22 @@ class DexscreenerClient:
411
596
  config = FilterConfig(price_change_threshold=0.02)
412
597
  await client.subscribe_pairs("ethereum", ["0x..."], callback, filter=config)
413
598
  """
599
+ # Import validators at the top of the method to avoid circular imports
600
+ from ..core.validators import (
601
+ validate_addresses_list,
602
+ validate_callback,
603
+ validate_chain_id,
604
+ validate_filter_config,
605
+ validate_interval,
606
+ )
607
+
608
+ # Validate inputs before any async operations
609
+ chain_id = validate_chain_id(chain_id)
610
+ pair_addresses = validate_addresses_list(pair_addresses, "pair_addresses", chain_id=chain_id)
611
+ callback = validate_callback(callback)
612
+ filter = validate_filter_config(filter)
613
+ interval = validate_interval(interval)
614
+
414
615
  # Handle single pair address for backward compatibility
415
616
  for pair_address in pair_addresses:
416
617
  subscription_key = f"{chain_id}:{pair_address}"
@@ -455,6 +656,7 @@ class DexscreenerClient:
455
656
 
456
657
  actual_callback = filtered_callback
457
658
  else:
659
+ # This should not happen as validate_filter_config already checks this
458
660
  raise ValueError(f"Invalid filter type: {type(filter)}. Must be bool or FilterConfig")
459
661
 
460
662
  # Store subscription info
@@ -498,6 +700,22 @@ class DexscreenerClient:
498
700
  callback=handle_usdc_pairs
499
701
  )
500
702
  """
703
+ # Import validators at the top of the method to avoid circular imports
704
+ from ..core.validators import (
705
+ validate_addresses_list,
706
+ validate_callback,
707
+ validate_chain_id,
708
+ validate_filter_config,
709
+ validate_interval,
710
+ )
711
+
712
+ # Validate inputs before any async operations
713
+ chain_id = validate_chain_id(chain_id)
714
+ token_addresses = validate_addresses_list(token_addresses, "token_addresses", chain_id=chain_id)
715
+ callback = validate_callback(callback)
716
+ filter = validate_filter_config(filter)
717
+ interval = validate_interval(interval)
718
+
501
719
  # Handle multiple token addresses
502
720
  for token_address in token_addresses:
503
721
  subscription_key = f"token:{chain_id}:{token_address}"
@@ -550,6 +768,7 @@ class DexscreenerClient:
550
768
 
551
769
  actual_callback = filtered_callback
552
770
  else:
771
+ # This should not happen as validate_filter_config already checks this
553
772
  raise ValueError(f"Invalid filter type: {type(filter)}. Must be bool or FilterConfig")
554
773
 
555
774
  # Store subscription info
@@ -598,6 +817,13 @@ class DexscreenerClient:
598
817
 
599
818
  async def unsubscribe_pairs(self, chain_id: str, pair_addresses: list[str]) -> None:
600
819
  """Unsubscribe from pair updates"""
820
+ # Import validators at the top of the method to avoid circular imports
821
+ from ..core.validators import validate_addresses_list, validate_chain_id
822
+
823
+ # Validate inputs
824
+ chain_id = validate_chain_id(chain_id)
825
+ pair_addresses = validate_addresses_list(pair_addresses, "pair_addresses", chain_id=chain_id)
826
+
601
827
  for pair_address in pair_addresses:
602
828
  subscription_key = f"{chain_id}:{pair_address}"
603
829
 
@@ -616,6 +842,13 @@ class DexscreenerClient:
616
842
 
617
843
  async def unsubscribe_tokens(self, chain_id: str, token_addresses: list[str]) -> None:
618
844
  """Unsubscribe from token pair updates"""
845
+ # Import validators at the top of the method to avoid circular imports
846
+ from ..core.validators import validate_addresses_list, validate_chain_id
847
+
848
+ # Validate inputs
849
+ chain_id = validate_chain_id(chain_id)
850
+ token_addresses = validate_addresses_list(token_addresses, "token_addresses", chain_id=chain_id)
851
+
619
852
  for token_address in token_addresses:
620
853
  subscription_key = f"token:{chain_id}:{token_address}"
621
854